Add conflict tracing and pair-local repair

This commit is contained in:
Jan Petykiewicz 2026-04-02 14:39:39 -07:00
commit 42e46c67e0
27 changed files with 6981 additions and 142 deletions

76
DOCS.md
View file

@ -128,8 +128,65 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
| Field | Default | Description | | Field | Default | Description |
| :-- | :-- | :-- | | :-- | :-- | :-- |
| `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. | | `capture_expanded` | `False` | Record expanded nodes for diagnostics and visualization. |
| `capture_conflict_trace` | `False` | Capture authoritative post-reverify conflict trace entries for debugging negotiated-congestion failures. |
| `capture_frontier_trace` | `False` | Run an analysis-only reroute for reached-but-colliding nets and capture prune causes near their final conflict hotspots. |
## 7. 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. `RoutingRunResult.metrics` is an immutable per-run snapshot.
@ -158,6 +215,10 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
- `move_cache_abs_hits` / `move_cache_abs_misses`: Absolute move-geometry cache activity. - `move_cache_abs_hits` / `move_cache_abs_misses`: Absolute move-geometry cache activity.
- `move_cache_rel_hits` / `move_cache_rel_misses`: Relative move-geometry cache activity. - `move_cache_rel_hits` / `move_cache_rel_misses`: Relative move-geometry cache activity.
- `guidance_match_moves`: Number of moves that matched the reroute guidance seed and received the guidance bonus.
- `guidance_match_moves_straight`, `guidance_match_moves_bend90`, `guidance_match_moves_sbend`: Guidance-match counts split by move type.
- `guidance_bonus_applied`: Total reroute-guidance bonus subtracted from move costs across the run.
- `guidance_bonus_applied_straight`, `guidance_bonus_applied_bend90`, `guidance_bonus_applied_sbend`: Guidance bonus totals split by move type.
- `static_safe_cache_hits`: Reuse count for the static-safe admission cache. - `static_safe_cache_hits`: Reuse count for the static-safe admission cache.
- `hard_collision_cache_hits`: Reuse count for the hard-collision cache. - `hard_collision_cache_hits`: Reuse count for the hard-collision cache.
- `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity. - `congestion_cache_hits` / `congestion_cache_misses`: Per-search congestion-cache activity.
@ -199,14 +260,21 @@ Use `RoutingProblem.initial_paths` to provide semantic per-net seeds. Seeds are
- `verify_dynamic_candidate_nets`: Total candidate net ids returned by the dynamic net-envelope broad phase during final verification. - `verify_dynamic_candidate_nets`: Total candidate net ids returned by the dynamic net-envelope broad phase during final verification.
- `verify_dynamic_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification. - `verify_dynamic_exact_pair_checks`: Number of exact geometry-pair checks performed during dynamic-path verification.
## 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=...)`. Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`.
The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**. The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**.
Use `scripts/diff_performance_baseline.py` to compare a fresh local run against that baseline. The counter baseline is currently observational and is not enforced as a CI gate. 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 and `scripts/record_frontier_trace.py` for hotspot-adjacent prune traces. The counter baseline is currently observational and is not enforced as a CI gate.
## 9. Tuning Notes ## 11. Tuning Notes
### Speed vs. optimality ### Speed vs. optimality

View file

@ -42,6 +42,7 @@ The search state is a snapped Manhattan `(x, y, r)` port. From each state the ro
- 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. - The visibility subsystem keeps a lazy static corner index for default `tangent_corner` guidance and only builds the exact corner-to-corner graph on demand for `exact_corner` queries.
- `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration. - `use_tiered_strategy` can swap in a cheaper bend proxy on the first congestion iteration.
- Negotiated congestion now re-verifies every reached-target path at the end of each iteration against the final installed dynamic geometry, and it stops early if the conflict graph stalls for consecutive iterations. - Negotiated congestion now re-verifies every reached-target path at the end of each iteration against the final installed dynamic geometry, and it stops early if the conflict graph stalls for consecutive iterations.
- After best-snapshot restoration, the router runs a bounded pair-local scratch reroute on final two-net reached-target conflict pairs. That repair phase clones static obstacles from the live collision world, treats all outside-pair geometry as fixed blockers, tries both pair orders, and only keeps the result if whole-set reverify improves.
- Final `RoutingResult` validity is determined by explicit post-route verification, not only by search-time pruning. - Final `RoutingResult` validity is determined by explicit post-route verification, not only by search-time pruning.
## Performance Visibility ## Performance Visibility

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

View file

@ -1,20 +1,22 @@
# Performance Baseline # Performance Baseline
Generated on 2026-04-01 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`. 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. 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.
| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls | | Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |
| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | | :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| example_01_simple_route | 0.0036 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 3 | | example_01_simple_route | 0.0040 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 4 |
| example_02_congestion_resolution | 0.3297 | 3 | 3 | 3 | 1 | 3 | 366 | 1164 | 1413 | 668 | 0 | 0 | 0 | 35 | | example_02_congestion_resolution | 0.3378 | 3 | 3 | 3 | 1 | 3 | 366 | 1164 | 1413 | 668 | 0 | 0 | 0 | 38 |
| example_03_locked_paths | 0.1832 | 2 | 2 | 2 | 2 | 2 | 191 | 657 | 904 | 307 | 0 | 0 | 0 | 14 | | 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.0260 | 2 | 2 | 2 | 1 | 2 | 15 | 70 | 123 | 65 | 0 | 0 | 0 | 6 | | 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.2348 | 3 | 3 | 3 | 2 | 6 | 286 | 1243 | 1624 | 681 | 0 | 0 | 155 | 15 | | 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.1953 | 3 | 3 | 3 | 3 | 3 | 240 | 682 | 1026 | 629 | 0 | 0 | 0 | 9 | | 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.1945 | 10 | 10 | 10 | 1 | 10 | 78 | 383 | 372 | 227 | 0 | 0 | 0 | 30 | | 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.0177 | 2 | 2 | 2 | 2 | 2 | 18 | 56 | 78 | 56 | 0 | 0 | 0 | 6 | | 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 | | example_09_unroutable_best_effort | 0.0058 | 1 | 0 | 0 | 1 | 1 | 3 | 13 | 16 | 10 | 0 | 0 | 0 | 1 |
## Full Counter Set ## Full Counter Set
@ -22,6 +24,13 @@ Use `scripts/diff_performance_baseline.py` to compare a fresh run against that s
Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters. Each scenario entry in `docs/performance_baseline.json` records the full `RouteMetrics` snapshot, including cache, index, congestion, and verification counters.
These counters are currently observational only and are not enforced as CI regression gates. 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`
Tracked metric keys: Tracked metric keys:
nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, iteration_reverify_calls, iteration_reverified_nets, iteration_conflicting_nets, iteration_conflict_edges, nets_carried_forward, score_component_calls, score_component_total_ns, path_cost_calls, danger_map_lookup_calls, danger_map_cache_hits, danger_map_cache_misses, danger_map_query_calls, danger_map_total_ns, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, 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 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

View file

@ -1,9 +1,9 @@
{ {
"generated_on": "2026-04-01", "generated_on": "2026-04-02",
"generator": "scripts/record_performance_baseline.py", "generator": "scripts/record_performance_baseline.py",
"scenarios": [ "scenarios": [
{ {
"duration_s": 0.0035884700482711196, "duration_s": 0.003964120987802744,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -34,6 +34,14 @@
"dynamic_path_objects_added": 3, "dynamic_path_objects_added": 3,
"dynamic_path_objects_removed": 2, "dynamic_path_objects_removed": 2,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -49,6 +57,10 @@
"nets_reached_target": 1, "nets_reached_target": 1,
"nets_routed": 1, "nets_routed": 1,
"nodes_expanded": 2, "nodes_expanded": 2,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 0, "path_cost_calls": 0,
"pruned_closed_set": 0, "pruned_closed_set": 0,
"pruned_cost": 4, "pruned_cost": 4,
@ -73,7 +85,7 @@
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 1, "route_iterations": 1,
"score_component_calls": 11, "score_component_calls": 11,
"score_component_total_ns": 16010, "score_component_total_ns": 18064,
"static_net_tree_rebuilds": 1, "static_net_tree_rebuilds": 1,
"static_raw_tree_rebuilds": 0, "static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 1, "static_safe_cache_hits": 1,
@ -81,7 +93,7 @@
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 0, "verify_dynamic_candidate_nets": 0,
"verify_dynamic_exact_pair_checks": 0, "verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 3, "verify_path_report_calls": 4,
"verify_static_buffer_ops": 0, "verify_static_buffer_ops": 0,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
@ -103,7 +115,7 @@
"valid_results": 1 "valid_results": 1
}, },
{ {
"duration_s": 0.32969290704932064, "duration_s": 0.3377689190674573,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -134,6 +146,14 @@
"dynamic_path_objects_added": 49, "dynamic_path_objects_added": 49,
"dynamic_path_objects_removed": 34, "dynamic_path_objects_removed": 34,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -149,6 +169,10 @@
"nets_reached_target": 3, "nets_reached_target": 3,
"nets_routed": 3, "nets_routed": 3,
"nodes_expanded": 366, "nodes_expanded": 366,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 14, "path_cost_calls": 14,
"pruned_closed_set": 157, "pruned_closed_set": 157,
"pruned_cost": 208, "pruned_cost": 208,
@ -173,15 +197,15 @@
"refinement_windows_considered": 10, "refinement_windows_considered": 10,
"route_iterations": 1, "route_iterations": 1,
"score_component_calls": 976, "score_component_calls": 976,
"score_component_total_ns": 1091130, "score_component_total_ns": 1140704,
"static_net_tree_rebuilds": 3, "static_net_tree_rebuilds": 3,
"static_raw_tree_rebuilds": 0, "static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 1, "static_safe_cache_hits": 1,
"static_tree_rebuilds": 2, "static_tree_rebuilds": 2,
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 84, "verify_dynamic_candidate_nets": 88,
"verify_dynamic_exact_pair_checks": 82, "verify_dynamic_exact_pair_checks": 86,
"verify_path_report_calls": 35, "verify_path_report_calls": 38,
"verify_static_buffer_ops": 0, "verify_static_buffer_ops": 0,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
@ -203,7 +227,7 @@
"valid_results": 3 "valid_results": 3
}, },
{ {
"duration_s": 0.18321374501101673, "duration_s": 0.1929313091095537,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -234,6 +258,14 @@
"dynamic_path_objects_added": 27, "dynamic_path_objects_added": 27,
"dynamic_path_objects_removed": 20, "dynamic_path_objects_removed": 20,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -249,6 +281,10 @@
"nets_reached_target": 2, "nets_reached_target": 2,
"nets_routed": 2, "nets_routed": 2,
"nodes_expanded": 191, "nodes_expanded": 191,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 9, "path_cost_calls": 9,
"pruned_closed_set": 97, "pruned_closed_set": 97,
"pruned_cost": 140, "pruned_cost": 140,
@ -273,16 +309,16 @@
"refinement_windows_considered": 2, "refinement_windows_considered": 2,
"route_iterations": 2, "route_iterations": 2,
"score_component_calls": 504, "score_component_calls": 504,
"score_component_total_ns": 556716, "score_component_total_ns": 565410,
"static_net_tree_rebuilds": 2, "static_net_tree_rebuilds": 2,
"static_raw_tree_rebuilds": 1, "static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 1, "static_safe_cache_hits": 1,
"static_tree_rebuilds": 1, "static_tree_rebuilds": 1,
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 8, "verify_dynamic_candidate_nets": 9,
"verify_dynamic_exact_pair_checks": 8, "verify_dynamic_exact_pair_checks": 9,
"verify_path_report_calls": 14, "verify_path_report_calls": 16,
"verify_static_buffer_ops": 72, "verify_static_buffer_ops": 81,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
"visibility_corner_index_builds": 2, "visibility_corner_index_builds": 2,
@ -303,7 +339,7 @@
"valid_results": 2 "valid_results": 2
}, },
{ {
"duration_s": 0.026024609920568764, "duration_s": 0.02791503700427711,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -334,6 +370,14 @@
"dynamic_path_objects_added": 21, "dynamic_path_objects_added": 21,
"dynamic_path_objects_removed": 14, "dynamic_path_objects_removed": 14,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -349,6 +393,10 @@
"nets_reached_target": 2, "nets_reached_target": 2,
"nets_routed": 2, "nets_routed": 2,
"nodes_expanded": 15, "nodes_expanded": 15,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 0, "path_cost_calls": 0,
"pruned_closed_set": 2, "pruned_closed_set": 2,
"pruned_cost": 25, "pruned_cost": 25,
@ -373,15 +421,15 @@
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 1, "route_iterations": 1,
"score_component_calls": 90, "score_component_calls": 90,
"score_component_total_ns": 97738, "score_component_total_ns": 100083,
"static_net_tree_rebuilds": 2, "static_net_tree_rebuilds": 2,
"static_raw_tree_rebuilds": 0, "static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 1, "static_safe_cache_hits": 1,
"static_tree_rebuilds": 1, "static_tree_rebuilds": 1,
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 6, "verify_dynamic_candidate_nets": 9,
"verify_dynamic_exact_pair_checks": 0, "verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 6, "verify_path_report_calls": 8,
"verify_static_buffer_ops": 0, "verify_static_buffer_ops": 0,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
@ -403,28 +451,28 @@
"valid_results": 2 "valid_results": 2
}, },
{ {
"duration_s": 0.23484283208381385, "duration_s": 0.23665715800598264,
"metrics": { "metrics": {
"congestion_cache_hits": 2, "congestion_cache_hits": 4,
"congestion_cache_misses": 155, "congestion_cache_misses": 149,
"congestion_candidate_ids": 19, "congestion_candidate_ids": 32,
"congestion_candidate_nets": 15, "congestion_candidate_nets": 23,
"congestion_candidate_precheck_hits": 135, "congestion_candidate_precheck_hits": 131,
"congestion_candidate_precheck_misses": 22, "congestion_candidate_precheck_misses": 22,
"congestion_candidate_precheck_skips": 0, "congestion_candidate_precheck_skips": 0,
"congestion_check_calls": 155, "congestion_check_calls": 149,
"congestion_exact_pair_checks": 18, "congestion_exact_pair_checks": 30,
"congestion_grid_net_cache_hits": 12, "congestion_grid_net_cache_hits": 16,
"congestion_grid_net_cache_misses": 25, "congestion_grid_net_cache_misses": 28,
"congestion_grid_span_cache_hits": 11, "congestion_grid_span_cache_hits": 15,
"congestion_grid_span_cache_misses": 4, "congestion_grid_span_cache_misses": 7,
"congestion_lazy_requeues": 0, "congestion_lazy_requeues": 0,
"congestion_lazy_resolutions": 0, "congestion_lazy_resolutions": 0,
"congestion_net_envelope_cache_hits": 134, "congestion_net_envelope_cache_hits": 128,
"congestion_net_envelope_cache_misses": 43, "congestion_net_envelope_cache_misses": 43,
"congestion_presence_cache_hits": 185, "congestion_presence_cache_hits": 200,
"congestion_presence_cache_misses": 30, "congestion_presence_cache_misses": 30,
"congestion_presence_skips": 58, "congestion_presence_skips": 77,
"danger_map_cache_hits": 0, "danger_map_cache_hits": 0,
"danger_map_cache_misses": 0, "danger_map_cache_misses": 0,
"danger_map_lookup_calls": 0, "danger_map_lookup_calls": 0,
@ -434,30 +482,42 @@
"dynamic_path_objects_added": 49, "dynamic_path_objects_added": 49,
"dynamic_path_objects_removed": 37, "dynamic_path_objects_removed": 37,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 687.5,
"guidance_bonus_applied_bend90": 500.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 187.5,
"guidance_match_moves": 11,
"guidance_match_moves_bend90": 8,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 3,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 1, "iteration_conflict_edges": 1,
"iteration_conflicting_nets": 2, "iteration_conflicting_nets": 2,
"iteration_reverified_nets": 6, "iteration_reverified_nets": 6,
"iteration_reverify_calls": 2, "iteration_reverify_calls": 2,
"move_cache_abs_hits": 253, "move_cache_abs_hits": 385,
"move_cache_abs_misses": 1371, "move_cache_abs_misses": 1306,
"move_cache_rel_hits": 1269, "move_cache_rel_hits": 1204,
"move_cache_rel_misses": 102, "move_cache_rel_misses": 102,
"moves_added": 681, "moves_added": 696,
"moves_generated": 1624, "moves_generated": 1691,
"nets_carried_forward": 0, "nets_carried_forward": 0,
"nets_reached_target": 6, "nets_reached_target": 6,
"nets_routed": 6, "nets_routed": 6,
"nodes_expanded": 286, "nodes_expanded": 299,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 2, "path_cost_calls": 2,
"pruned_closed_set": 139, "pruned_closed_set": 159,
"pruned_cost": 505, "pruned_cost": 537,
"pruned_hard_collision": 14, "pruned_hard_collision": 14,
"ray_cast_calls": 1243, "ray_cast_calls": 1284,
"ray_cast_calls_expand_forward": 280, "ray_cast_calls_expand_forward": 293,
"ray_cast_calls_expand_snap": 3, "ray_cast_calls_expand_snap": 3,
"ray_cast_calls_other": 0, "ray_cast_calls_other": 0,
"ray_cast_calls_straight_static": 951, "ray_cast_calls_straight_static": 979,
"ray_cast_calls_visibility_build": 0, "ray_cast_calls_visibility_build": 0,
"ray_cast_calls_visibility_query": 0, "ray_cast_calls_visibility_query": 0,
"ray_cast_calls_visibility_tangent": 9, "ray_cast_calls_visibility_tangent": 9,
@ -472,16 +532,16 @@
"refinement_static_bounds_checked": 0, "refinement_static_bounds_checked": 0,
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 2, "route_iterations": 2,
"score_component_calls": 1198, "score_component_calls": 1245,
"score_component_total_ns": 1194981, "score_component_total_ns": 1260961,
"static_net_tree_rebuilds": 3, "static_net_tree_rebuilds": 3,
"static_raw_tree_rebuilds": 0, "static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 3, "static_safe_cache_hits": 9,
"static_tree_rebuilds": 1, "static_tree_rebuilds": 1,
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 8, "verify_dynamic_candidate_nets": 8,
"verify_dynamic_exact_pair_checks": 12, "verify_dynamic_exact_pair_checks": 12,
"verify_path_report_calls": 15, "verify_path_report_calls": 18,
"verify_static_buffer_ops": 0, "verify_static_buffer_ops": 0,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
@ -493,7 +553,7 @@
"visibility_point_queries": 0, "visibility_point_queries": 0,
"visibility_tangent_candidate_corner_checks": 70, "visibility_tangent_candidate_corner_checks": 70,
"visibility_tangent_candidate_ray_tests": 9, "visibility_tangent_candidate_ray_tests": 9,
"visibility_tangent_candidate_scans": 280, "visibility_tangent_candidate_scans": 293,
"warm_start_paths_built": 2, "warm_start_paths_built": 2,
"warm_start_paths_used": 2 "warm_start_paths_used": 2
}, },
@ -503,7 +563,7 @@
"valid_results": 3 "valid_results": 3
}, },
{ {
"duration_s": 0.19533946400042623, "duration_s": 0.19982667709700763,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -529,11 +589,19 @@
"danger_map_cache_misses": 731, "danger_map_cache_misses": 731,
"danger_map_lookup_calls": 1914, "danger_map_lookup_calls": 1914,
"danger_map_query_calls": 731, "danger_map_query_calls": 731,
"danger_map_total_ns": 18697751, "danger_map_total_ns": 18959782,
"dynamic_grid_rebuilds": 0, "dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 54, "dynamic_path_objects_added": 54,
"dynamic_path_objects_removed": 36, "dynamic_path_objects_removed": 36,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 18, "hard_collision_cache_hits": 18,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -549,6 +617,10 @@
"nets_reached_target": 3, "nets_reached_target": 3,
"nets_routed": 3, "nets_routed": 3,
"nodes_expanded": 240, "nodes_expanded": 240,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 0, "path_cost_calls": 0,
"pruned_closed_set": 108, "pruned_closed_set": 108,
"pruned_cost": 204, "pruned_cost": 204,
@ -573,7 +645,7 @@
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 3, "route_iterations": 3,
"score_component_calls": 842, "score_component_calls": 842,
"score_component_total_ns": 21016472, "score_component_total_ns": 21338709,
"static_net_tree_rebuilds": 3, "static_net_tree_rebuilds": 3,
"static_raw_tree_rebuilds": 3, "static_raw_tree_rebuilds": 3,
"static_safe_cache_hits": 141, "static_safe_cache_hits": 141,
@ -581,8 +653,8 @@
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 0, "verify_dynamic_candidate_nets": 0,
"verify_dynamic_exact_pair_checks": 0, "verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 9, "verify_path_report_calls": 12,
"verify_static_buffer_ops": 54, "verify_static_buffer_ops": 72,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
"visibility_corner_index_builds": 3, "visibility_corner_index_builds": 3,
@ -603,7 +675,7 @@
"valid_results": 3 "valid_results": 3
}, },
{ {
"duration_s": 0.19448363897390664, "duration_s": 0.20046633295714855,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -629,11 +701,19 @@
"danger_map_cache_misses": 448, "danger_map_cache_misses": 448,
"danger_map_lookup_calls": 681, "danger_map_lookup_calls": 681,
"danger_map_query_calls": 448, "danger_map_query_calls": 448,
"danger_map_total_ns": 10973251, "danger_map_total_ns": 11017087,
"dynamic_grid_rebuilds": 0, "dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 132, "dynamic_path_objects_added": 132,
"dynamic_path_objects_removed": 88, "dynamic_path_objects_removed": 88,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -649,6 +729,10 @@
"nets_reached_target": 10, "nets_reached_target": 10,
"nets_routed": 10, "nets_routed": 10,
"nodes_expanded": 78, "nodes_expanded": 78,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 0, "path_cost_calls": 0,
"pruned_closed_set": 20, "pruned_closed_set": 20,
"pruned_cost": 64, "pruned_cost": 64,
@ -673,16 +757,16 @@
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 1, "route_iterations": 1,
"score_component_calls": 291, "score_component_calls": 291,
"score_component_total_ns": 11824081, "score_component_total_ns": 11869917,
"static_net_tree_rebuilds": 10, "static_net_tree_rebuilds": 10,
"static_raw_tree_rebuilds": 1, "static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 6, "static_safe_cache_hits": 6,
"static_tree_rebuilds": 10, "static_tree_rebuilds": 10,
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 264, "verify_dynamic_candidate_nets": 370,
"verify_dynamic_exact_pair_checks": 40, "verify_dynamic_exact_pair_checks": 56,
"verify_path_report_calls": 30, "verify_path_report_calls": 40,
"verify_static_buffer_ops": 132, "verify_static_buffer_ops": 176,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
"visibility_corner_index_builds": 10, "visibility_corner_index_builds": 10,
@ -703,7 +787,7 @@
"valid_results": 10 "valid_results": 10
}, },
{ {
"duration_s": 0.017700672964565456, "duration_s": 0.01759456400759518,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -734,6 +818,14 @@
"dynamic_path_objects_added": 18, "dynamic_path_objects_added": 18,
"dynamic_path_objects_removed": 12, "dynamic_path_objects_removed": 12,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -749,6 +841,10 @@
"nets_reached_target": 2, "nets_reached_target": 2,
"nets_routed": 2, "nets_routed": 2,
"nodes_expanded": 18, "nodes_expanded": 18,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 0, "path_cost_calls": 0,
"pruned_closed_set": 6, "pruned_closed_set": 6,
"pruned_cost": 16, "pruned_cost": 16,
@ -773,7 +869,7 @@
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 2, "route_iterations": 2,
"score_component_calls": 72, "score_component_calls": 72,
"score_component_total_ns": 85969, "score_component_total_ns": 85864,
"static_net_tree_rebuilds": 2, "static_net_tree_rebuilds": 2,
"static_raw_tree_rebuilds": 0, "static_raw_tree_rebuilds": 0,
"static_safe_cache_hits": 2, "static_safe_cache_hits": 2,
@ -781,7 +877,7 @@
"timeout_events": 0, "timeout_events": 0,
"verify_dynamic_candidate_nets": 0, "verify_dynamic_candidate_nets": 0,
"verify_dynamic_exact_pair_checks": 0, "verify_dynamic_exact_pair_checks": 0,
"verify_path_report_calls": 6, "verify_path_report_calls": 8,
"verify_static_buffer_ops": 0, "verify_static_buffer_ops": 0,
"visibility_builds": 0, "visibility_builds": 0,
"visibility_corner_hits_exact": 0, "visibility_corner_hits_exact": 0,
@ -803,7 +899,7 @@
"valid_results": 2 "valid_results": 2
}, },
{ {
"duration_s": 0.005781985004432499, "duration_s": 0.005838233977556229,
"metrics": { "metrics": {
"congestion_cache_hits": 0, "congestion_cache_hits": 0,
"congestion_cache_misses": 0, "congestion_cache_misses": 0,
@ -829,11 +925,19 @@
"danger_map_cache_misses": 20, "danger_map_cache_misses": 20,
"danger_map_lookup_calls": 30, "danger_map_lookup_calls": 30,
"danger_map_query_calls": 20, "danger_map_query_calls": 20,
"danger_map_total_ns": 536009, "danger_map_total_ns": 523870,
"dynamic_grid_rebuilds": 0, "dynamic_grid_rebuilds": 0,
"dynamic_path_objects_added": 2, "dynamic_path_objects_added": 2,
"dynamic_path_objects_removed": 1, "dynamic_path_objects_removed": 1,
"dynamic_tree_rebuilds": 0, "dynamic_tree_rebuilds": 0,
"guidance_bonus_applied": 0.0,
"guidance_bonus_applied_bend90": 0.0,
"guidance_bonus_applied_sbend": 0.0,
"guidance_bonus_applied_straight": 0.0,
"guidance_match_moves": 0,
"guidance_match_moves_bend90": 0,
"guidance_match_moves_sbend": 0,
"guidance_match_moves_straight": 0,
"hard_collision_cache_hits": 0, "hard_collision_cache_hits": 0,
"iteration_conflict_edges": 0, "iteration_conflict_edges": 0,
"iteration_conflicting_nets": 0, "iteration_conflicting_nets": 0,
@ -849,6 +953,10 @@
"nets_reached_target": 0, "nets_reached_target": 0,
"nets_routed": 1, "nets_routed": 1,
"nodes_expanded": 3, "nodes_expanded": 3,
"pair_local_search_accepts": 0,
"pair_local_search_attempts": 0,
"pair_local_search_nodes_expanded": 0,
"pair_local_search_pairs_considered": 0,
"path_cost_calls": 0, "path_cost_calls": 0,
"pruned_closed_set": 0, "pruned_closed_set": 0,
"pruned_cost": 4, "pruned_cost": 4,
@ -873,7 +981,7 @@
"refinement_windows_considered": 0, "refinement_windows_considered": 0,
"route_iterations": 1, "route_iterations": 1,
"score_component_calls": 14, "score_component_calls": 14,
"score_component_total_ns": 574907, "score_component_total_ns": 563611,
"static_net_tree_rebuilds": 1, "static_net_tree_rebuilds": 1,
"static_raw_tree_rebuilds": 1, "static_raw_tree_rebuilds": 1,
"static_safe_cache_hits": 0, "static_safe_cache_hits": 0,

View file

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

View file

@ -64,6 +64,7 @@ def _span_to_bounds(
class PathVerificationDetail: class PathVerificationDetail:
report: RoutingReport report: RoutingReport
conflicting_net_ids: tuple[str, ...] = () conflicting_net_ids: tuple[str, ...] = ()
component_conflicts: tuple[tuple[int, str, int], ...] = ()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -140,8 +141,19 @@ class RoutingWorld:
def _ensure_dynamic_grid(self) -> None: def _ensure_dynamic_grid(self) -> None:
self._dynamic_paths.ensure_grid() self._dynamic_paths.ensure_grid()
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: def add_path(
self._dynamic_paths.add_path(net_id, geometry, dilated_geometry=dilated_geometry) 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: def remove_path(self, net_id: str) -> None:
self._dynamic_paths.remove_path(net_id) self._dynamic_paths.remove_path(net_id)
@ -651,7 +663,13 @@ class RoutingWorld:
frozen_net_ids=frozen_net_ids, frozen_net_ids=frozen_net_ids,
) )
def verify_path_details(self, net_id: str, components: Sequence[ComponentResult]) -> PathVerificationDetail: def verify_path_details(
self,
net_id: str,
components: Sequence[ComponentResult],
*,
capture_component_conflicts: bool = False,
) -> PathVerificationDetail:
if self.metrics is not None: if self.metrics is not None:
self.metrics.total_verify_path_report_calls += 1 self.metrics.total_verify_path_report_calls += 1
static_collision_count = 0 static_collision_count = 0
@ -659,6 +677,7 @@ class RoutingWorld:
self_collision_count = 0 self_collision_count = 0
total_length = sum(component.length for component in components) total_length = sum(component.length for component in components)
conflicting_net_ids: set[str] = set() conflicting_net_ids: set[str] = set()
component_conflicts: set[tuple[int, str, int]] = set()
static_obstacles = self._static_obstacles static_obstacles = self._static_obstacles
dynamic_paths = self._dynamic_paths dynamic_paths = self._dynamic_paths
@ -682,7 +701,7 @@ class RoutingWorld:
static_collision_count += 1 static_collision_count += 1
if dynamic_paths.dilated: if dynamic_paths.dilated:
for component in components: for component_index, component in enumerate(components):
test_geometries = component.dilated_physical_geometry test_geometries = component.dilated_physical_geometry
component_hits = [] component_hits = []
for new_geometry in test_geometries: for new_geometry in test_geometries:
@ -695,6 +714,14 @@ class RoutingWorld:
tree_geometry = dynamic_paths.dilated[obj_id] tree_geometry = dynamic_paths.dilated[obj_id]
if _has_non_touching_overlap(new_geometry, tree_geometry): if _has_non_touching_overlap(new_geometry, tree_geometry):
component_hits.append(hit_net_id) 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 break
if component_hits: if component_hits:
@ -715,6 +742,7 @@ class RoutingWorld:
total_length=total_length, total_length=total_length,
), ),
conflicting_net_ids=tuple(sorted(conflicting_net_ids)), 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: def verify_path_report(self, net_id: str, components: Sequence[ComponentResult]) -> RoutingReport:

View file

@ -21,6 +21,7 @@ class DynamicPathIndex:
"engine", "engine",
"index", "index",
"geometries", "geometries",
"component_indexes",
"dilated", "dilated",
"dilated_bounds", "dilated_bounds",
"net_envelope_index", "net_envelope_index",
@ -43,6 +44,7 @@ class DynamicPathIndex:
self.engine = engine self.engine = engine
self.index = rtree.index.Index() self.index = rtree.index.Index()
self.geometries: dict[int, tuple[str, Polygon]] = {} self.geometries: dict[int, tuple[str, Polygon]] = {}
self.component_indexes: dict[int, int] = {}
self.dilated: dict[int, Polygon] = {} self.dilated: dict[int, Polygon] = {}
self.dilated_bounds: dict[int, tuple[float, float, float, float]] = {} self.dilated_bounds: dict[int, tuple[float, float, float, float]] = {}
self.net_envelope_index = rtree.index.Index() self.net_envelope_index = rtree.index.Index()
@ -176,7 +178,13 @@ class DynamicPathIndex:
if not net_counts: if not net_counts:
self.grid_net_counts.pop(cell, None) self.grid_net_counts.pop(cell, None)
def add_path(self, net_id: str, geometry: Sequence[Polygon], dilated_geometry: Sequence[Polygon]) -> None: def add_path(
self,
net_id: str,
geometry: Sequence[Polygon],
dilated_geometry: Sequence[Polygon],
component_indexes: Sequence[int] | None = None,
) -> None:
if self.engine.metrics is not None: if self.engine.metrics is not None:
self.engine.metrics.total_dynamic_path_objects_added += len(geometry) self.engine.metrics.total_dynamic_path_objects_added += len(geometry)
cell_size = self.engine.grid_cell_size cell_size = self.engine.grid_cell_size
@ -186,6 +194,7 @@ class DynamicPathIndex:
dilated = dilated_geometry[index] dilated = dilated_geometry[index]
dilated_bounds = dilated.bounds dilated_bounds = dilated.bounds
self.geometries[obj_id] = (net_id, polygon) self.geometries[obj_id] = (net_id, polygon)
self.component_indexes[obj_id] = index if component_indexes is None else component_indexes[index]
self.dilated[obj_id] = dilated self.dilated[obj_id] = dilated
self.dilated_bounds[obj_id] = dilated_bounds self.dilated_bounds[obj_id] = dilated_bounds
self.index.insert(obj_id, dilated_bounds) self.index.insert(obj_id, dilated_bounds)
@ -211,6 +220,7 @@ class DynamicPathIndex:
self._unregister_grid_membership(obj_id, net_id) self._unregister_grid_membership(obj_id, net_id)
self.index.delete(obj_id, self.dilated_bounds[obj_id]) self.index.delete(obj_id, self.dilated_bounds[obj_id])
del self.geometries[obj_id] del self.geometries[obj_id]
del self.component_indexes[obj_id]
del self.dilated[obj_id] del self.dilated[obj_id]
del self.dilated_bounds[obj_id] del self.dilated_bounds[obj_id]
obj_id_set = self.net_to_obj_ids.get(net_id) obj_id_set = self.net_to_obj_ids.get(net_id)

View file

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

View file

@ -12,6 +12,8 @@ if TYPE_CHECKING:
RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"] RoutingOutcome = Literal["completed", "colliding", "partial", "unroutable"]
ConflictTraceStage = Literal["iteration", "restored_best", "final"]
FrontierTraceReason = Literal["closed_set", "hard_collision", "self_collision", "cost"]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -30,6 +32,52 @@ class RoutingReport:
return self.collision_count == 0 return self.collision_count == 0
@dataclass(frozen=True, slots=True)
class ComponentConflictTrace:
other_net_id: str
self_component_index: int
other_component_index: int
@dataclass(frozen=True, slots=True)
class NetConflictTrace:
net_id: str
outcome: RoutingOutcome
reached_target: bool
report: RoutingReport
conflicting_net_ids: tuple[str, ...] = ()
component_conflicts: tuple[ComponentConflictTrace, ...] = ()
@dataclass(frozen=True, slots=True)
class ConflictTraceEntry:
stage: ConflictTraceStage
iteration: int | None
completed_net_ids: tuple[str, ...]
conflict_edges: tuple[tuple[str, str], ...]
nets: tuple[NetConflictTrace, ...]
@dataclass(frozen=True, slots=True)
class FrontierPruneSample:
reason: FrontierTraceReason
move_type: str
hotspot_index: int
parent_state: tuple[int, int, int]
end_state: tuple[int, int, int]
@dataclass(frozen=True, slots=True)
class NetFrontierTrace:
net_id: str
hotspot_bounds: tuple[tuple[float, float, float, float], ...]
pruned_closed_set: int
pruned_hard_collision: int
pruned_self_collision: int
pruned_cost: int
samples: tuple[FrontierPruneSample, ...] = ()
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RouteMetrics: class RouteMetrics:
nodes_expanded: int nodes_expanded: int
@ -62,6 +110,14 @@ class RouteMetrics:
move_cache_abs_misses: int move_cache_abs_misses: int
move_cache_rel_hits: int move_cache_rel_hits: int
move_cache_rel_misses: int move_cache_rel_misses: int
guidance_match_moves: int
guidance_match_moves_straight: int
guidance_match_moves_bend90: int
guidance_match_moves_sbend: int
guidance_bonus_applied: float
guidance_bonus_applied_straight: float
guidance_bonus_applied_bend90: float
guidance_bonus_applied_sbend: float
static_safe_cache_hits: int static_safe_cache_hits: int
hard_collision_cache_hits: int hard_collision_cache_hits: int
congestion_cache_hits: int congestion_cache_hits: int
@ -123,6 +179,10 @@ class RouteMetrics:
refinement_candidates_built: int refinement_candidates_built: int
refinement_candidates_verified: int refinement_candidates_verified: int
refinement_candidates_accepted: int refinement_candidates_accepted: int
pair_local_search_pairs_considered: int
pair_local_search_attempts: int
pair_local_search_accepts: int
pair_local_search_nodes_expanded: int
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -169,3 +229,5 @@ class RoutingRunResult:
results_by_net: dict[str, RoutingResult] results_by_net: dict[str, RoutingResult]
metrics: RouteMetrics metrics: RouteMetrics
expanded_nodes: tuple[tuple[int, int, int], ...] = () expanded_nodes: tuple[tuple[int, int, int], ...] = ()
conflict_trace: tuple[ConflictTraceEntry, ...] = ()
frontier_trace: tuple[NetFrontierTrace, ...] = ()

View file

@ -145,11 +145,14 @@ def add_node(
move_type: MoveKind, move_type: MoveKind,
cache_key: tuple, cache_key: tuple,
) -> None: ) -> None:
frontier_trace = config.frontier_trace
metrics.moves_generated += 1 metrics.moves_generated += 1
metrics.total_moves_generated += 1 metrics.total_moves_generated += 1
state = result.end_port.as_tuple() state = result.end_port.as_tuple()
new_lower_bound_g = parent.g_cost + result.length new_lower_bound_g = parent.g_cost + result.length
if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR: if state in closed_set and closed_set[state] <= new_lower_bound_g + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_closed_set += 1 metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1 metrics.total_pruned_closed_set += 1
return return
@ -158,6 +161,8 @@ def add_node(
end_p = result.end_port end_p = result.end_port
if cache_key in context.hard_collision_set: if cache_key in context.hard_collision_set:
if frontier_trace is not None:
frontier_trace.record("hard_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
context.metrics.total_hard_collision_cache_hits += 1 context.metrics.total_hard_collision_cache_hits += 1
metrics.pruned_hard_collision += 1 metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1 metrics.total_pruned_hard_collision += 1
@ -174,29 +179,62 @@ def add_node(
collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p) collision_found = ce.check_move_static(result, start_port=parent_p, end_port=end_p)
if collision_found: if collision_found:
context.hard_collision_set.add(cache_key) context.hard_collision_set.add(cache_key)
if frontier_trace is not None:
frontier_trace.record("hard_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_hard_collision += 1 metrics.pruned_hard_collision += 1
metrics.total_pruned_hard_collision += 1 metrics.total_pruned_hard_collision += 1
return return
context.static_safe_cache.add(cache_key) context.static_safe_cache.add(cache_key)
if config.self_collision_check and component_hits_ancestor_chain(result, parent): if config.self_collision_check and component_hits_ancestor_chain(result, parent):
if frontier_trace is not None:
frontier_trace.record("self_collision", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
return return
move_cost = context.cost_evaluator.score_component( move_cost = context.cost_evaluator.score_component(
result, result,
start_port=parent_p, start_port=parent_p,
) )
next_seed_index = None
if (
config.guidance_seed is not None
and parent.seed_index is not None
and parent.seed_index < len(config.guidance_seed)
and result.move_spec == config.guidance_seed[parent.seed_index]
):
context.metrics.total_guidance_match_moves += 1
if result.move_type == "straight":
context.metrics.total_guidance_match_moves_straight += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_straight += applied_bonus
elif result.move_type == "bend90":
context.metrics.total_guidance_match_moves_bend90 += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_bend90 += applied_bonus
else:
context.metrics.total_guidance_match_moves_sbend += 1
applied_bonus = config.guidance_bonus
context.metrics.total_guidance_bonus_applied_sbend += applied_bonus
context.metrics.total_guidance_bonus_applied += applied_bonus
move_cost = max(0.001, move_cost - applied_bonus)
next_seed_index = parent.seed_index + 1
if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost: if config.max_cost is not None and parent.g_cost + move_cost > config.max_cost:
if frontier_trace is not None:
frontier_trace.record("cost", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_cost += 1 metrics.pruned_cost += 1
metrics.total_pruned_cost += 1 metrics.total_pruned_cost += 1
return return
if move_cost > 1e12: if move_cost > 1e12:
if frontier_trace is not None:
frontier_trace.record("cost", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_cost += 1 metrics.pruned_cost += 1
metrics.total_pruned_cost += 1 metrics.total_pruned_cost += 1
return return
if state in closed_set and closed_set[state] <= parent.g_cost + move_cost + TOLERANCE_LINEAR: if 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.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1 metrics.total_pruned_closed_set += 1
return return
@ -233,6 +271,8 @@ def add_node(
g_cost = parent.g_cost + move_cost g_cost = parent.g_cost + move_cost
if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR: if state in closed_set and closed_set[state] <= g_cost + TOLERANCE_LINEAR:
if frontier_trace is not None:
frontier_trace.record("closed_set", move_type, parent.port.as_tuple(), state, result.total_dilated_bounds)
metrics.pruned_closed_set += 1 metrics.pruned_closed_set += 1
metrics.total_pruned_closed_set += 1 metrics.total_pruned_closed_set += 1
return return
@ -242,6 +282,16 @@ def add_node(
target, target,
min_bend_radius=context.min_bend_radius, 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.moves_added += 1
metrics.total_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) return sorted((v for v in out if v > 0), reverse=True)
def _distance_to_bounds_in_heading(
current: Port,
bounds: tuple[float, float, float, float],
) -> float:
min_x, min_y, max_x, max_y = bounds
if current.r == 0:
return max(0.0, max_x - current.x)
if current.r == 90:
return max(0.0, max_y - current.y)
if current.r == 180:
return max(0.0, current.x - min_x)
return max(0.0, current.y - min_y)
def _should_cap_straights_to_bounds(context: AStarContext) -> bool:
return (
not context.options.congestion.warm_start_enabled
and len(context.problem.nets) >= 8
)
def _sbend_forward_span(offset: float, radius: float) -> float | None: def _sbend_forward_span(offset: float, radius: float) -> float | None:
abs_offset = abs(offset) abs_offset = abs(offset)
if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius: if abs_offset <= TOLERANCE_LINEAR or radius <= 0 or abs_offset >= 2.0 * radius:
@ -211,6 +232,8 @@ def expand_moves(
net_width=net_width, net_width=net_width,
caller="expand_forward", caller="expand_forward",
) )
if _should_cap_straights_to_bounds(context):
max_reach = min(max_reach, _distance_to_bounds_in_heading(cp, context.problem.bounds))
candidate_lengths = [ candidate_lengths = [
search_options.min_straight_length, search_options.min_straight_length,
max_reach, max_reach,

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.model import resolve_bend_geometry from inire.model import resolve_bend_geometry
@ -12,6 +12,51 @@ if TYPE_CHECKING:
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.model import RoutingOptions, RoutingProblem from inire.model import RoutingOptions, RoutingProblem
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.seeds import PathSegmentSeed
@dataclass(slots=True)
class FrontierTraceCollector:
hotspot_bounds: tuple[tuple[float, float, float, float], ...]
sample_limit: int = 64
pruned_closed_set: int = 0
pruned_hard_collision: int = 0
pruned_self_collision: int = 0
pruned_cost: int = 0
samples: list[tuple[str, str, int, tuple[int, int, int], tuple[int, int, int]]] = field(default_factory=list)
def _matching_hotspot_index(self, bounds: tuple[float, float, float, float]) -> int | None:
for idx, hotspot_bounds in enumerate(self.hotspot_bounds):
if (
bounds[0] < hotspot_bounds[2]
and bounds[2] > hotspot_bounds[0]
and bounds[1] < hotspot_bounds[3]
and bounds[3] > hotspot_bounds[1]
):
return idx
return None
def record(
self,
reason: str,
move_type: str,
parent_state: tuple[int, int, int],
end_state: tuple[int, int, int],
bounds: tuple[float, float, float, float],
) -> None:
hotspot_index = self._matching_hotspot_index(bounds)
if hotspot_index is None:
return
if reason == "closed_set":
self.pruned_closed_set += 1
elif reason == "hard_collision":
self.pruned_hard_collision += 1
elif reason == "self_collision":
self.pruned_self_collision += 1
else:
self.pruned_cost += 1
if len(self.samples) < self.sample_limit:
self.samples.append((reason, move_type, hotspot_index, parent_state, end_state))
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -20,6 +65,9 @@ class SearchRunConfig:
bend_physical_geometry: BendPhysicalGeometry bend_physical_geometry: BendPhysicalGeometry
bend_clip_margin: float | None bend_clip_margin: float | None
node_limit: int node_limit: int
guidance_seed: tuple[PathSegmentSeed, ...] | None = None
guidance_bonus: float = 0.0
frontier_trace: FrontierTraceCollector | None = None
return_partial: bool = False return_partial: bool = False
store_expanded: bool = False store_expanded: bool = False
skip_congestion: bool = False skip_congestion: bool = False
@ -33,6 +81,9 @@ class SearchRunConfig:
*, *,
bend_collision_type: BendCollisionModel | None = None, bend_collision_type: BendCollisionModel | None = None,
node_limit: int | None = None, node_limit: int | None = None,
guidance_seed: tuple[PathSegmentSeed, ...] | None = None,
guidance_bonus: float = 0.0,
frontier_trace: FrontierTraceCollector | None = None,
return_partial: bool = False, return_partial: bool = False,
store_expanded: bool = False, store_expanded: bool = False,
skip_congestion: bool = False, skip_congestion: bool = False,
@ -49,6 +100,9 @@ class SearchRunConfig:
bend_physical_geometry=bend_physical_geometry, bend_physical_geometry=bend_physical_geometry,
bend_clip_margin=search.bend_clip_margin, bend_clip_margin=search.bend_clip_margin,
node_limit=search.node_limit if node_limit is None else node_limit, node_limit=search.node_limit if node_limit is None else node_limit,
guidance_seed=guidance_seed,
guidance_bonus=float(guidance_bonus),
frontier_trace=frontier_trace,
return_partial=return_partial, return_partial=return_partial,
store_expanded=store_expanded, store_expanded=store_expanded,
skip_congestion=skip_congestion, skip_congestion=skip_congestion,
@ -67,6 +121,7 @@ class AStarNode:
"component_result", "component_result",
"base_move_cost", "base_move_cost",
"cache_key", "cache_key",
"seed_index",
"congestion_resolved", "congestion_resolved",
) )
@ -80,6 +135,7 @@ class AStarNode:
*, *,
base_move_cost: float = 0.0, base_move_cost: float = 0.0,
cache_key: tuple | None = None, cache_key: tuple | None = None,
seed_index: int | None = None,
congestion_resolved: bool = True, congestion_resolved: bool = True,
) -> None: ) -> None:
self.port = port self.port = port
@ -90,6 +146,7 @@ class AStarNode:
self.component_result = component_result self.component_result = component_result
self.base_move_cost = base_move_cost self.base_move_cost = base_move_cost
self.cache_key = cache_key self.cache_key = cache_key
self.seed_index = seed_index
self.congestion_resolved = congestion_resolved self.congestion_resolved = congestion_resolved
def __lt__(self, other: AStarNode) -> bool: def __lt__(self, other: AStarNode) -> bool:
@ -128,6 +185,14 @@ class AStarMetrics:
"total_move_cache_abs_misses", "total_move_cache_abs_misses",
"total_move_cache_rel_hits", "total_move_cache_rel_hits",
"total_move_cache_rel_misses", "total_move_cache_rel_misses",
"total_guidance_match_moves",
"total_guidance_match_moves_straight",
"total_guidance_match_moves_bend90",
"total_guidance_match_moves_sbend",
"total_guidance_bonus_applied",
"total_guidance_bonus_applied_straight",
"total_guidance_bonus_applied_bend90",
"total_guidance_bonus_applied_sbend",
"total_static_safe_cache_hits", "total_static_safe_cache_hits",
"total_hard_collision_cache_hits", "total_hard_collision_cache_hits",
"total_congestion_cache_hits", "total_congestion_cache_hits",
@ -189,6 +254,10 @@ class AStarMetrics:
"total_refinement_candidates_built", "total_refinement_candidates_built",
"total_refinement_candidates_verified", "total_refinement_candidates_verified",
"total_refinement_candidates_accepted", "total_refinement_candidates_accepted",
"total_pair_local_search_pairs_considered",
"total_pair_local_search_attempts",
"total_pair_local_search_accepts",
"total_pair_local_search_nodes_expanded",
"last_expanded_nodes", "last_expanded_nodes",
"nodes_expanded", "nodes_expanded",
"moves_generated", "moves_generated",
@ -229,6 +298,14 @@ class AStarMetrics:
self.total_move_cache_abs_misses = 0 self.total_move_cache_abs_misses = 0
self.total_move_cache_rel_hits = 0 self.total_move_cache_rel_hits = 0
self.total_move_cache_rel_misses = 0 self.total_move_cache_rel_misses = 0
self.total_guidance_match_moves = 0
self.total_guidance_match_moves_straight = 0
self.total_guidance_match_moves_bend90 = 0
self.total_guidance_match_moves_sbend = 0
self.total_guidance_bonus_applied = 0.0
self.total_guidance_bonus_applied_straight = 0.0
self.total_guidance_bonus_applied_bend90 = 0.0
self.total_guidance_bonus_applied_sbend = 0.0
self.total_static_safe_cache_hits = 0 self.total_static_safe_cache_hits = 0
self.total_hard_collision_cache_hits = 0 self.total_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 0 self.total_congestion_cache_hits = 0
@ -290,6 +367,10 @@ class AStarMetrics:
self.total_refinement_candidates_built = 0 self.total_refinement_candidates_built = 0
self.total_refinement_candidates_verified = 0 self.total_refinement_candidates_verified = 0
self.total_refinement_candidates_accepted = 0 self.total_refinement_candidates_accepted = 0
self.total_pair_local_search_pairs_considered = 0
self.total_pair_local_search_attempts = 0
self.total_pair_local_search_accepts = 0
self.total_pair_local_search_nodes_expanded = 0
self.last_expanded_nodes: list[tuple[int, int, int]] = [] self.last_expanded_nodes: list[tuple[int, int, int]] = []
self.nodes_expanded = 0 self.nodes_expanded = 0
self.moves_generated = 0 self.moves_generated = 0
@ -329,6 +410,14 @@ class AStarMetrics:
self.total_move_cache_abs_misses = 0 self.total_move_cache_abs_misses = 0
self.total_move_cache_rel_hits = 0 self.total_move_cache_rel_hits = 0
self.total_move_cache_rel_misses = 0 self.total_move_cache_rel_misses = 0
self.total_guidance_match_moves = 0
self.total_guidance_match_moves_straight = 0
self.total_guidance_match_moves_bend90 = 0
self.total_guidance_match_moves_sbend = 0
self.total_guidance_bonus_applied = 0.0
self.total_guidance_bonus_applied_straight = 0.0
self.total_guidance_bonus_applied_bend90 = 0.0
self.total_guidance_bonus_applied_sbend = 0.0
self.total_static_safe_cache_hits = 0 self.total_static_safe_cache_hits = 0
self.total_hard_collision_cache_hits = 0 self.total_hard_collision_cache_hits = 0
self.total_congestion_cache_hits = 0 self.total_congestion_cache_hits = 0
@ -390,6 +479,10 @@ class AStarMetrics:
self.total_refinement_candidates_built = 0 self.total_refinement_candidates_built = 0
self.total_refinement_candidates_verified = 0 self.total_refinement_candidates_verified = 0
self.total_refinement_candidates_accepted = 0 self.total_refinement_candidates_accepted = 0
self.total_pair_local_search_pairs_considered = 0
self.total_pair_local_search_attempts = 0
self.total_pair_local_search_accepts = 0
self.total_pair_local_search_nodes_expanded = 0
def reset_per_route(self) -> None: def reset_per_route(self) -> None:
self.nodes_expanded = 0 self.nodes_expanded = 0
@ -432,6 +525,14 @@ class AStarMetrics:
move_cache_abs_misses=self.total_move_cache_abs_misses, move_cache_abs_misses=self.total_move_cache_abs_misses,
move_cache_rel_hits=self.total_move_cache_rel_hits, move_cache_rel_hits=self.total_move_cache_rel_hits,
move_cache_rel_misses=self.total_move_cache_rel_misses, move_cache_rel_misses=self.total_move_cache_rel_misses,
guidance_match_moves=self.total_guidance_match_moves,
guidance_match_moves_straight=self.total_guidance_match_moves_straight,
guidance_match_moves_bend90=self.total_guidance_match_moves_bend90,
guidance_match_moves_sbend=self.total_guidance_match_moves_sbend,
guidance_bonus_applied=self.total_guidance_bonus_applied,
guidance_bonus_applied_straight=self.total_guidance_bonus_applied_straight,
guidance_bonus_applied_bend90=self.total_guidance_bonus_applied_bend90,
guidance_bonus_applied_sbend=self.total_guidance_bonus_applied_sbend,
static_safe_cache_hits=self.total_static_safe_cache_hits, static_safe_cache_hits=self.total_static_safe_cache_hits,
hard_collision_cache_hits=self.total_hard_collision_cache_hits, hard_collision_cache_hits=self.total_hard_collision_cache_hits,
congestion_cache_hits=self.total_congestion_cache_hits, congestion_cache_hits=self.total_congestion_cache_hits,
@ -493,6 +594,10 @@ class AStarMetrics:
refinement_candidates_built=self.total_refinement_candidates_built, refinement_candidates_built=self.total_refinement_candidates_built,
refinement_candidates_verified=self.total_refinement_candidates_verified, refinement_candidates_verified=self.total_refinement_candidates_verified,
refinement_candidates_accepted=self.total_refinement_candidates_accepted, 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 dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from inire.model import NetOrder, NetSpec, resolve_bend_geometry from inire.geometry.collision import RoutingWorld
from inire.results import RoutingOutcome, RoutingReport, RoutingResult from inire.model import NetOrder, NetSpec, RoutingProblem, resolve_bend_geometry
from inire.router._astar_types import AStarContext, AStarMetrics, SearchRunConfig 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._search import route_astar
from inire.router._seed_materialization import materialize_path_seed from inire.router._seed_materialization import materialize_path_seed
from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap
from inire.router.refiner import PathRefiner from inire.router.refiner import PathRefiner
if TYPE_CHECKING: if TYPE_CHECKING:
@ -17,6 +29,7 @@ if TYPE_CHECKING:
from shapely.geometry import Polygon from shapely.geometry import Polygon
from inire.geometry.collision import PathVerificationDetail
from inire.geometry.components import ComponentResult from inire.geometry.components import ComponentResult
@ -46,12 +59,20 @@ class _IterationReview:
completed_net_ids: set[str] completed_net_ids: set[str]
total_dynamic_collisions: int total_dynamic_collisions: int
@dataclass(frozen=True, slots=True)
class _PairLocalTarget:
net_ids: tuple[str, str]
class PathFinder: class PathFinder:
__slots__ = ( __slots__ = (
"context", "context",
"metrics", "metrics",
"refiner", "refiner",
"accumulated_expanded_nodes", "accumulated_expanded_nodes",
"conflict_trace",
"frontier_trace",
) )
def __init__( def __init__(
@ -67,14 +88,23 @@ class PathFinder:
self.context.cost_evaluator.danger_map.metrics = self.metrics self.context.cost_evaluator.danger_map.metrics = self.metrics
self.refiner = PathRefiner(self.context) self.refiner = PathRefiner(self.context)
self.accumulated_expanded_nodes: list[tuple[int, int, int]] = [] self.accumulated_expanded_nodes: list[tuple[int, int, int]] = []
self.conflict_trace: list[ConflictTraceEntry] = []
self.frontier_trace: list[NetFrontierTrace] = []
def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None: def _install_path(self, net_id: str, path: Sequence[ComponentResult]) -> None:
all_geoms: list[Polygon] = [] all_geoms: list[Polygon] = []
all_dilated: list[Polygon] = [] all_dilated: list[Polygon] = []
for result in path: component_indexes: list[int] = []
for component_index, result in enumerate(path):
all_geoms.extend(result.collision_geometry) all_geoms.extend(result.collision_geometry)
all_dilated.extend(result.dilated_collision_geometry) all_dilated.extend(result.dilated_collision_geometry)
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( def _routing_order(
self, self,
@ -227,6 +257,493 @@ class PathFinder:
state.results = dict(state.best_results) state.results = dict(state.best_results)
self._replace_installed_paths(state, state.results) self._replace_installed_paths(state, state.results)
def _capture_conflict_trace_entry(
self,
state: _RoutingState,
*,
stage: str,
iteration: int | None,
results: dict[str, RoutingResult],
details_by_net: dict[str, PathVerificationDetail],
review: _IterationReview,
) -> None:
if not self.context.options.diagnostics.capture_conflict_trace:
return
nets = []
for net_id in state.ordered_net_ids:
result = results.get(net_id)
if result is None:
result = RoutingResult(net_id=net_id, path=(), reached_target=False)
detail = details_by_net.get(net_id)
component_conflicts = ()
conflicting_net_ids = ()
if detail is not None:
conflicting_net_ids = detail.conflicting_net_ids
component_conflicts = tuple(
ComponentConflictTrace(
other_net_id=other_net_id,
self_component_index=self_component_index,
other_component_index=other_component_index,
)
for self_component_index, other_net_id, other_component_index in detail.component_conflicts
)
nets.append(
NetConflictTrace(
net_id=net_id,
outcome=result.outcome,
reached_target=result.reached_target,
report=result.report,
conflicting_net_ids=tuple(conflicting_net_ids),
component_conflicts=component_conflicts,
)
)
self.conflict_trace.append(
ConflictTraceEntry(
stage=stage, # type: ignore[arg-type]
iteration=iteration,
completed_net_ids=tuple(sorted(review.completed_net_ids)),
conflict_edges=tuple(sorted(review.conflict_edges)),
nets=tuple(nets),
)
)
def _build_frontier_hotspot_bounds(
self,
state: _RoutingState,
net_id: str,
details_by_net: dict[str, PathVerificationDetail],
) -> tuple[tuple[float, float, float, float], ...]:
result = state.results.get(net_id)
detail = details_by_net.get(net_id)
if result is None or detail is None or not result.path:
return ()
hotspot_bounds: list[tuple[float, float, float, float]] = []
seen: set[tuple[float, float, float, float]] = set()
margin = max(5.0, self.context.cost_evaluator.collision_engine.clearance * 2.0)
for self_component_index, other_net_id, other_component_index in detail.component_conflicts:
other_result = state.results.get(other_net_id)
if other_result is None or not other_result.path:
continue
if self_component_index >= len(result.path) or other_component_index >= len(other_result.path):
continue
left_component = result.path[self_component_index]
right_component = other_result.path[other_component_index]
overlap_found = False
for left_poly in left_component.dilated_physical_geometry:
for right_poly in right_component.dilated_physical_geometry:
if not left_poly.intersects(right_poly) or left_poly.touches(right_poly):
continue
overlap = left_poly.intersection(right_poly)
if overlap.is_empty:
continue
buffered = overlap.buffer(margin, join_style="mitre").bounds
if buffered not in seen:
seen.add(buffered)
hotspot_bounds.append(buffered)
overlap_found = True
if overlap_found:
continue
left_bounds = left_component.total_dilated_bounds
right_bounds = right_component.total_dilated_bounds
if (
left_bounds[0] < right_bounds[2]
and left_bounds[2] > right_bounds[0]
and left_bounds[1] < right_bounds[3]
and left_bounds[3] > right_bounds[1]
):
buffered = (
max(left_bounds[0], right_bounds[0]) - margin,
max(left_bounds[1], right_bounds[1]) - margin,
min(left_bounds[2], right_bounds[2]) + margin,
min(left_bounds[3], right_bounds[3]) + margin,
)
if buffered not in seen:
seen.add(buffered)
hotspot_bounds.append(buffered)
return tuple(hotspot_bounds)
def _analyze_results(
self,
ordered_net_ids: Sequence[str],
results: dict[str, RoutingResult],
*,
capture_component_conflicts: bool,
count_iteration_metrics: bool,
) -> tuple[dict[str, RoutingResult], dict[str, PathVerificationDetail], _IterationReview]:
if count_iteration_metrics:
self.metrics.total_iteration_reverify_calls += 1
conflict_edges: set[tuple[str, str]] = set()
conflicting_nets: set[str] = set()
completed_net_ids: set[str] = set()
total_dynamic_collisions = 0
analyzed_results = dict(results)
details_by_net: dict[str, PathVerificationDetail] = {}
for net_id in ordered_net_ids:
result = results.get(net_id)
if not result or not result.path or not result.reached_target:
continue
if count_iteration_metrics:
self.metrics.total_iteration_reverified_nets += 1
detail = self.context.cost_evaluator.collision_engine.verify_path_details(
net_id,
result.path,
capture_component_conflicts=capture_component_conflicts,
)
details_by_net[net_id] = detail
analyzed_results[net_id] = RoutingResult(
net_id=net_id,
path=result.path,
reached_target=result.reached_target,
report=detail.report,
)
total_dynamic_collisions += detail.report.dynamic_collision_count
if analyzed_results[net_id].outcome == "completed":
completed_net_ids.add(net_id)
if not detail.conflicting_net_ids:
continue
conflicting_nets.add(net_id)
for other_net_id in detail.conflicting_net_ids:
conflicting_nets.add(other_net_id)
if other_net_id == net_id:
continue
conflict_edges.add(tuple(sorted((net_id, other_net_id))))
if count_iteration_metrics:
self.metrics.total_iteration_conflicting_nets += len(conflicting_nets)
self.metrics.total_iteration_conflict_edges += len(conflict_edges)
return (
analyzed_results,
details_by_net,
_IterationReview(
conflicting_nets=conflicting_nets,
conflict_edges=conflict_edges,
completed_net_ids=completed_net_ids,
total_dynamic_collisions=total_dynamic_collisions,
),
)
def _capture_frontier_trace(
self,
state: _RoutingState,
final_results: dict[str, RoutingResult],
) -> None:
if not self.context.options.diagnostics.capture_frontier_trace:
return
self.frontier_trace = []
state.results = dict(final_results)
state.results, details_by_net, _ = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
original_metrics = self.metrics
original_context_metrics = self.context.metrics
original_engine_metrics = self.context.cost_evaluator.collision_engine.metrics
original_danger_metrics = None
if self.context.cost_evaluator.danger_map is not None:
original_danger_metrics = self.context.cost_evaluator.danger_map.metrics
try:
for net_id in state.ordered_net_ids:
result = state.results.get(net_id)
detail = details_by_net.get(net_id)
if result is None or detail is None or not result.reached_target:
continue
if detail.report.dynamic_collision_count == 0 or not detail.component_conflicts:
continue
hotspot_bounds = self._build_frontier_hotspot_bounds(state, net_id, details_by_net)
if not hotspot_bounds:
continue
scratch_metrics = AStarMetrics()
self.context.metrics = scratch_metrics
self.context.cost_evaluator.collision_engine.metrics = scratch_metrics
if self.context.cost_evaluator.danger_map is not None:
self.context.cost_evaluator.danger_map.metrics = scratch_metrics
guidance_seed = result.as_seed().segments if result.path else None
guidance_bonus = 0.0
if guidance_seed:
guidance_bonus = max(10.0, self.context.options.objective.bend_penalty * 0.25)
collector = FrontierTraceCollector(hotspot_bounds=hotspot_bounds)
run_config = SearchRunConfig.from_options(
self.context.options,
return_partial=True,
store_expanded=False,
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
frontier_trace=collector,
self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=self.context.options.search.node_limit,
)
self.context.cost_evaluator.collision_engine.remove_path(net_id)
try:
route_astar(
state.net_specs[net_id].start,
state.net_specs[net_id].target,
state.net_specs[net_id].width,
context=self.context,
metrics=scratch_metrics,
net_id=net_id,
config=run_config,
)
finally:
if result.path:
self._install_path(net_id, result.path)
self.frontier_trace.append(
NetFrontierTrace(
net_id=net_id,
hotspot_bounds=hotspot_bounds,
pruned_closed_set=collector.pruned_closed_set,
pruned_hard_collision=collector.pruned_hard_collision,
pruned_self_collision=collector.pruned_self_collision,
pruned_cost=collector.pruned_cost,
samples=tuple(
FrontierPruneSample(
reason=reason, # type: ignore[arg-type]
move_type=move_type,
hotspot_index=hotspot_index,
parent_state=parent_state,
end_state=end_state,
)
for reason, move_type, hotspot_index, parent_state, end_state in collector.samples
),
)
)
finally:
self.metrics = original_metrics
self.context.metrics = original_context_metrics
self.context.cost_evaluator.collision_engine.metrics = original_engine_metrics
if self.context.cost_evaluator.danger_map is not None:
self.context.cost_evaluator.danger_map.metrics = original_danger_metrics
def _whole_set_is_better(
self,
candidate_results: dict[str, RoutingResult],
candidate_review: _IterationReview,
incumbent_results: dict[str, RoutingResult],
incumbent_review: _IterationReview,
) -> bool:
candidate_completed = len(candidate_review.completed_net_ids)
incumbent_completed = len(incumbent_review.completed_net_ids)
if candidate_completed != incumbent_completed:
return candidate_completed > incumbent_completed
candidate_edges = len(candidate_review.conflict_edges)
incumbent_edges = len(incumbent_review.conflict_edges)
if candidate_edges != incumbent_edges:
return candidate_edges < incumbent_edges
if candidate_review.total_dynamic_collisions != incumbent_review.total_dynamic_collisions:
return candidate_review.total_dynamic_collisions < incumbent_review.total_dynamic_collisions
candidate_length = sum(
result.report.total_length
for result in candidate_results.values()
if result.reached_target
)
incumbent_length = sum(
result.report.total_length
for result in incumbent_results.values()
if result.reached_target
)
if abs(candidate_length - incumbent_length) > 1e-6:
return candidate_length < incumbent_length
return False
def _collect_pair_local_targets(
self,
state: _RoutingState,
results: dict[str, RoutingResult],
review: _IterationReview,
) -> list[_PairLocalTarget]:
if not review.conflict_edges:
return []
order_index = {net_id: idx for idx, net_id in enumerate(state.ordered_net_ids)}
seen_net_ids: set[str] = set()
targets: list[_PairLocalTarget] = []
for left_net_id, right_net_id in sorted(review.conflict_edges):
if left_net_id in seen_net_ids or right_net_id in seen_net_ids:
return []
left_result = results.get(left_net_id)
right_result = results.get(right_net_id)
if (
left_result is None
or right_result is None
or not left_result.reached_target
or not right_result.reached_target
):
continue
seen_net_ids.update((left_net_id, right_net_id))
targets.append(_PairLocalTarget(net_ids=(left_net_id, right_net_id)))
targets.sort(key=lambda target: min(order_index[target.net_ids[0]], order_index[target.net_ids[1]]))
return targets
def _build_pair_local_context(
self,
state: _RoutingState,
incumbent_results: dict[str, RoutingResult],
pair_net_ids: tuple[str, str],
) -> AStarContext:
problem = self.context.problem
objective = self.context.options.objective
static_obstacles = tuple(self.context.cost_evaluator.collision_engine._static_obstacles.geometries.values())
engine = RoutingWorld(
clearance=self.context.cost_evaluator.collision_engine.clearance,
safety_zone_radius=self.context.cost_evaluator.collision_engine.safety_zone_radius,
)
for obstacle in static_obstacles:
engine.add_static_obstacle(obstacle)
for net_id in state.ordered_net_ids:
if net_id in pair_net_ids:
continue
result = incumbent_results.get(net_id)
if result is None or not result.path:
continue
for component in result.path:
for polygon in component.physical_geometry:
engine.add_static_obstacle(polygon)
danger_map = DangerMap(bounds=problem.bounds)
danger_map.precompute(list(static_obstacles))
evaluator = CostEvaluator(
engine,
danger_map,
unit_length_cost=objective.unit_length_cost,
greedy_h_weight=self.context.cost_evaluator.greedy_h_weight,
bend_penalty=objective.bend_penalty,
sbend_penalty=objective.sbend_penalty,
danger_weight=objective.danger_weight,
)
return AStarContext(
evaluator,
RoutingProblem(
bounds=problem.bounds,
nets=tuple(state.net_specs[net_id] for net_id in state.ordered_net_ids),
static_obstacles=static_obstacles,
clearance=problem.clearance,
safety_zone_radius=problem.safety_zone_radius,
),
self.context.options,
metrics=AStarMetrics(),
)
def _run_pair_local_attempt(
self,
state: _RoutingState,
incumbent_results: dict[str, RoutingResult],
pair_order: tuple[str, str],
) -> tuple[dict[str, RoutingResult], int] | None:
local_context = self._build_pair_local_context(state, incumbent_results, pair_order)
local_results = dict(incumbent_results)
for net_id in pair_order:
net = state.net_specs[net_id]
guidance_result = incumbent_results.get(net_id)
guidance_seed = None
guidance_bonus = 0.0
if guidance_result and guidance_result.reached_target and guidance_result.path:
guidance_seed = guidance_result.as_seed().segments
guidance_bonus = max(10.0, self.context.options.objective.bend_penalty * 0.25)
run_config = SearchRunConfig.from_options(
self.context.options,
return_partial=False,
skip_congestion=True,
self_collision_check=(net_id in state.needs_self_collision_check),
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
node_limit=self.context.options.search.node_limit,
)
path = route_astar(
net.start,
net.target,
net.width,
context=local_context,
metrics=local_context.metrics,
net_id=net_id,
config=run_config,
)
if not path or path[-1].end_port != net.target:
return None
report = local_context.cost_evaluator.collision_engine.verify_path_report(net_id, path)
if not report.is_valid:
return None
local_results[net_id] = RoutingResult(
net_id=net_id,
path=tuple(path),
reached_target=True,
report=report,
)
for component in path:
for polygon in component.physical_geometry:
local_context.cost_evaluator.collision_engine.add_static_obstacle(polygon)
local_context.clear_static_caches()
return local_results, local_context.metrics.total_nodes_expanded
def _run_pair_local_search(self, state: _RoutingState) -> None:
state.results, _details_by_net, review = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
targets = self._collect_pair_local_targets(state, state.results, review)
if not targets:
return
for target in targets[:2]:
self.metrics.total_pair_local_search_pairs_considered += 1
incumbent_results = dict(state.results)
incumbent_review = review
accepted = False
for pair_order in (target.net_ids, target.net_ids[::-1]):
self.metrics.total_pair_local_search_attempts += 1
candidate = self._run_pair_local_attempt(state, incumbent_results, pair_order)
if candidate is None:
continue
candidate_results, nodes_expanded = candidate
self.metrics.total_pair_local_search_nodes_expanded += nodes_expanded
self._replace_installed_paths(state, candidate_results)
candidate_results, _candidate_details_by_net, candidate_review = self._analyze_results(
state.ordered_net_ids,
candidate_results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
if self._whole_set_is_better(
candidate_results,
candidate_review,
incumbent_results,
incumbent_review,
):
self.metrics.total_pair_local_search_accepts += 1
state.results = candidate_results
review = candidate_review
accepted = True
break
self._replace_installed_paths(state, incumbent_results)
if not accepted:
state.results = incumbent_results
self._replace_installed_paths(state, incumbent_results)
def _route_net_once( def _route_net_once(
self, self,
state: _RoutingState, state: _RoutingState,
@ -246,16 +763,25 @@ class PathFinder:
else: else:
coll_model, _ = resolve_bend_geometry(search) coll_model, _ = resolve_bend_geometry(search)
skip_congestion = False skip_congestion = False
guidance_seed = None
guidance_bonus = 0.0
if congestion.use_tiered_strategy and iteration == 0: if congestion.use_tiered_strategy and iteration == 0:
skip_congestion = True skip_congestion = True
if coll_model == "arc": if coll_model == "arc":
coll_model = "clipped_bbox" coll_model = "clipped_bbox"
elif iteration > 0:
guidance_result = state.results.get(net_id)
if guidance_result and guidance_result.reached_target and guidance_result.path:
guidance_seed = guidance_result.as_seed().segments
guidance_bonus = max(10.0, self.context.options.objective.bend_penalty * 0.25)
run_config = SearchRunConfig.from_options( run_config = SearchRunConfig.from_options(
self.context.options, self.context.options,
bend_collision_type=coll_model, bend_collision_type=coll_model,
return_partial=True, return_partial=True,
store_expanded=diagnostics.capture_expanded, store_expanded=diagnostics.capture_expanded,
guidance_seed=guidance_seed,
guidance_bonus=guidance_bonus,
skip_congestion=skip_congestion, skip_congestion=skip_congestion,
self_collision_check=(net_id in state.needs_self_collision_check), self_collision_check=(net_id in state.needs_self_collision_check),
node_limit=search.node_limit, node_limit=search.node_limit,
@ -303,7 +829,6 @@ class PathFinder:
congestion = self.context.options.congestion congestion = self.context.options.congestion
self.metrics.total_route_iterations += 1 self.metrics.total_route_iterations += 1
self.metrics.reset_per_route() self.metrics.reset_per_route()
if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None): if congestion.shuffle_nets and (iteration > 0 or state.initial_paths is None):
iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None iteration_seed = (congestion.seed + iteration) if congestion.seed is not None else None
random.Random(iteration_seed).shuffle(state.ordered_net_ids) random.Random(iteration_seed).shuffle(state.ordered_net_ids)
@ -326,45 +851,21 @@ class PathFinder:
return review return review
def _reverify_iteration_results(self, state: _RoutingState) -> _IterationReview: def _reverify_iteration_results(self, state: _RoutingState) -> _IterationReview:
self.metrics.total_iteration_reverify_calls += 1 state.results, details_by_net, review = self._analyze_results(
conflict_edges: set[tuple[str, str]] = set() state.ordered_net_ids,
conflicting_nets: set[str] = set() state.results,
completed_net_ids: set[str] = set() capture_component_conflicts=self.context.options.diagnostics.capture_conflict_trace,
total_dynamic_collisions = 0 count_iteration_metrics=True,
for net_id in state.ordered_net_ids:
result = state.results.get(net_id)
if not result or not result.path or not result.reached_target:
continue
self.metrics.total_iteration_reverified_nets += 1
detail = self.context.cost_evaluator.collision_engine.verify_path_details(net_id, result.path)
state.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 state.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))))
self.metrics.total_iteration_conflicting_nets += len(conflicting_nets)
self.metrics.total_iteration_conflict_edges += len(conflict_edges)
return _IterationReview(
conflicting_nets=conflicting_nets,
conflict_edges=conflict_edges,
completed_net_ids=completed_net_ids,
total_dynamic_collisions=total_dynamic_collisions,
) )
self._capture_conflict_trace_entry(
state,
stage="iteration",
iteration=self.metrics.total_route_iterations - 1,
results=state.results,
details_by_net=details_by_net,
review=review,
)
return review
def _run_iterations( def _run_iterations(
self, self,
@ -428,17 +929,38 @@ class PathFinder:
def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]: def _verify_results(self, state: _RoutingState) -> dict[str, RoutingResult]:
final_results: dict[str, RoutingResult] = {} final_results: dict[str, RoutingResult] = {}
details_by_net: dict[str, PathVerificationDetail] = {}
for net in self.context.problem.nets: for net in self.context.problem.nets:
result = state.results.get(net.net_id) result = state.results.get(net.net_id)
if not result or not result.path: if not result or not result.path:
final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False) final_results[net.net_id] = RoutingResult(net_id=net.net_id, path=(), reached_target=False)
continue continue
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( final_results[net.net_id] = RoutingResult(
net_id=net.net_id, net_id=net.net_id,
path=result.path, path=result.path,
reached_target=result.reached_target, reached_target=result.reached_target,
report=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 return final_results
@ -449,6 +971,8 @@ class PathFinder:
) -> dict[str, RoutingResult]: ) -> dict[str, RoutingResult]:
self.context.congestion_penalty = self.context.options.congestion.base_penalty self.context.congestion_penalty = self.context.options.congestion.base_penalty
self.accumulated_expanded_nodes = [] self.accumulated_expanded_nodes = []
self.conflict_trace = []
self.frontier_trace = []
self.metrics.reset_totals() self.metrics.reset_totals()
self.metrics.reset_per_route() self.metrics.reset_per_route()
@ -456,9 +980,29 @@ class PathFinder:
timed_out = self._run_iterations(state, iteration_callback) timed_out = self._run_iterations(state, iteration_callback)
self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes) self.accumulated_expanded_nodes = list(state.accumulated_expanded_nodes)
self._restore_best_iteration(state) self._restore_best_iteration(state)
if self.context.options.diagnostics.capture_conflict_trace:
state.results, details_by_net, review = self._analyze_results(
state.ordered_net_ids,
state.results,
capture_component_conflicts=True,
count_iteration_metrics=False,
)
self._capture_conflict_trace_entry(
state,
stage="restored_best",
iteration=None,
results=state.results,
details_by_net=details_by_net,
review=review,
)
if timed_out: if timed_out:
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) 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

@ -51,6 +51,7 @@ def route_astar(
start, start,
0.0, 0.0,
context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius), context.cost_evaluator.h_manhattan(start, target, min_bend_radius=context.min_bend_radius),
seed_index=0 if config.guidance_seed else None,
) )
heapq.heappush(open_set, start_node) heapq.heappush(open_set, start_node)
best_node = start_node best_node = start_node

View file

@ -15,6 +15,7 @@ from inire import (
RoutingOptions, RoutingOptions,
RoutingProblem, RoutingProblem,
RoutingResult, RoutingResult,
RoutingRunResult,
SearchOptions, SearchOptions,
) )
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
@ -34,6 +35,7 @@ _OBJECTIVE_FIELDS = set(ObjectiveWeights.__dataclass_fields__)
ScenarioOutcome = tuple[float, int, int, int] ScenarioOutcome = tuple[float, int, int, int]
ScenarioRun = Callable[[], ScenarioOutcome] ScenarioRun = Callable[[], ScenarioOutcome]
ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"] ScenarioSnapshotRun = Callable[[], "ScenarioSnapshot"]
TraceScenarioRun = Callable[[], RoutingRunResult]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -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: def _sum_metrics(metrics_list: tuple[RouteMetrics, ...]) -> RouteMetrics:
metric_names = RouteMetrics.__dataclass_fields__ metric_names = RouteMetrics.__dataclass_fields__
return RouteMetrics( return RouteMetrics(
@ -318,6 +333,24 @@ def run_example_05() -> ScenarioOutcome:
return snapshot_example_05().as_outcome() return snapshot_example_05().as_outcome()
def trace_example_05() -> RoutingRunResult:
netlist = {
"u_turn": (Port(50, 50, 0), Port(50, 70, 180)),
"loop": (Port(100, 100, 90), Port(100, 80, 270)),
"zig_zag": (Port(20, 150, 0), Port(180, 150, 0)),
}
widths = dict.fromkeys(netlist, 2.0)
_, _, _, pathfinder = _build_routing_stack(
bounds=(0, 0, 200, 200),
netlist=netlist,
widths=widths,
evaluator_kwargs={"bend_penalty": 50.0},
request_kwargs={"bend_radii": [20.0], "capture_conflict_trace": True, "capture_frontier_trace": True},
)
results = pathfinder.route_all()
return _make_run_result(results, pathfinder)
def snapshot_example_06() -> ScenarioSnapshot: def snapshot_example_06() -> ScenarioSnapshot:
bounds = (-20, -20, 170, 170) bounds = (-20, -20, 170, 170)
obstacles = [ obstacles = [
@ -391,6 +424,14 @@ def snapshot_example_07_no_warm_start() -> ScenarioSnapshot:
) )
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 _snapshot_example_07_variant( def _snapshot_example_07_variant(
name: str, name: str,
*, *,
@ -454,6 +495,68 @@ def _snapshot_example_07_variant(
return _make_snapshot(name, results, t1 - t0, pathfinder.metrics.snapshot()) return _make_snapshot(name, results, t1 - t0, pathfinder.metrics.snapshot())
def _trace_example_07_variant(
*,
warm_start_enabled: bool,
) -> RoutingRunResult:
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
end_y_base = 100
end_y_pitch = 800.0 / (num_nets - 1)
netlist = {}
for index in range(num_nets):
sy = int(round(start_y_base + index * 10.0))
ey = int(round(end_y_base + index * end_y_pitch))
netlist[f"net_{index:02d}"] = (Port(start_x, sy, 0), Port(end_x, ey, 0))
widths = dict.fromkeys(netlist, 2.0)
_, evaluator, metrics, pathfinder = _build_routing_stack(
bounds=bounds,
netlist=netlist,
widths=widths,
clearance=6.0,
obstacles=obstacles,
evaluator_kwargs={
"greedy_h_weight": 1.5,
"unit_length_cost": 0.1,
"bend_penalty": 100.0,
"sbend_penalty": 400.0,
},
request_kwargs={
"node_limit": 2000000,
"bend_radii": [50.0],
"sbend_radii": [50.0],
"bend_clip_margin": 10.0,
"max_iterations": 15,
"base_penalty": 100.0,
"multiplier": 1.4,
"net_order": "shortest",
"capture_expanded": True,
"capture_conflict_trace": True,
"capture_frontier_trace": True,
"shuffle_nets": True,
"seed": 42,
"warm_start_enabled": warm_start_enabled,
},
)
def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
_ = current_results
new_greedy = max(1.1, 1.5 - ((idx + 1) / 10.0) * 0.4)
evaluator.greedy_h_weight = new_greedy
metrics.reset_per_route()
results = pathfinder.route_all(iteration_callback=iteration_callback)
return _make_run_result(results, pathfinder)
def run_example_07() -> ScenarioOutcome: def run_example_07() -> ScenarioOutcome:
return snapshot_example_07().as_outcome() return snapshot_example_07().as_outcome()
@ -557,6 +660,15 @@ PERFORMANCE_SCENARIO_SNAPSHOTS: tuple[tuple[str, ScenarioSnapshotRun], ...] = (
("example_07_large_scale_routing_no_warm_start", snapshot_example_07_no_warm_start), ("example_07_large_scale_routing_no_warm_start", snapshot_example_07_no_warm_start),
) )
TRACE_SCENARIO_RUNS: tuple[tuple[str, TraceScenarioRun], ...] = (
("example_05_orientation_stress", trace_example_05),
("example_07_large_scale_routing", trace_example_07),
)
TRACE_PERFORMANCE_SCENARIO_RUNS: tuple[tuple[str, TraceScenarioRun], ...] = (
("example_07_large_scale_routing_no_warm_start", trace_example_07_no_warm_start),
)
def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]: def capture_all_scenario_snapshots() -> tuple[ScenarioSnapshot, ...]:
return tuple(run() for _, run in SCENARIO_SNAPSHOTS) return tuple(run() for _, run in SCENARIO_SNAPSHOTS)

View file

@ -22,8 +22,6 @@ from inire.router._astar_types import AStarContext
from inire.router._router import PathFinder, _IterationReview from inire.router._router import PathFinder, _IterationReview
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
def test_root_module_exports_only_stable_surface() -> None: def test_root_module_exports_only_stable_surface() -> None:
import inire import inire
@ -54,6 +52,8 @@ def test_route_problem_smoke() -> None:
assert set(run.results_by_net) == {"net1"} assert set(run.results_by_net) == {"net1"}
assert run.results_by_net["net1"].is_valid assert run.results_by_net["net1"].is_valid
assert run.conflict_trace == ()
assert run.frontier_trace == ()
def test_route_problem_supports_configs_and_debug_data() -> None: def test_route_problem_supports_configs_and_debug_data() -> None:
@ -94,6 +94,14 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
assert run.metrics.dynamic_tree_rebuilds >= 0 assert run.metrics.dynamic_tree_rebuilds >= 0
assert run.metrics.visibility_corner_index_builds >= 0 assert run.metrics.visibility_corner_index_builds >= 0
assert run.metrics.visibility_builds >= 0 assert run.metrics.visibility_builds >= 0
assert run.metrics.guidance_match_moves >= 0
assert run.metrics.guidance_match_moves_straight >= 0
assert run.metrics.guidance_match_moves_bend90 >= 0
assert run.metrics.guidance_match_moves_sbend >= 0
assert run.metrics.guidance_bonus_applied >= 0.0
assert run.metrics.guidance_bonus_applied_straight >= 0.0
assert run.metrics.guidance_bonus_applied_bend90 >= 0.0
assert run.metrics.guidance_bonus_applied_sbend >= 0.0
assert run.metrics.congestion_grid_span_cache_hits >= 0 assert run.metrics.congestion_grid_span_cache_hits >= 0
assert run.metrics.congestion_grid_span_cache_misses >= 0 assert run.metrics.congestion_grid_span_cache_misses >= 0
assert run.metrics.congestion_presence_cache_hits >= 0 assert run.metrics.congestion_presence_cache_hits >= 0
@ -112,6 +120,10 @@ def test_route_problem_supports_configs_and_debug_data() -> None:
assert run.metrics.congestion_candidate_ids >= 0 assert run.metrics.congestion_candidate_ids >= 0
assert run.metrics.verify_dynamic_candidate_nets >= 0 assert run.metrics.verify_dynamic_candidate_nets >= 0
assert run.metrics.verify_path_report_calls >= 0 assert run.metrics.verify_path_report_calls >= 0
assert run.metrics.pair_local_search_pairs_considered >= 0
assert run.metrics.pair_local_search_attempts >= 0
assert run.metrics.pair_local_search_accepts >= 0
assert run.metrics.pair_local_search_nodes_expanded >= 0
def test_iteration_callback_observes_reverified_conflicts() -> None: def test_iteration_callback_observes_reverified_conflicts() -> None:
@ -141,6 +153,120 @@ def test_iteration_callback_observes_reverified_conflicts() -> None:
assert results["vertical"].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: def test_reverify_iterations_stop_early_on_stalled_conflict_graph() -> None:
problem = RoutingProblem( problem = RoutingProblem(
bounds=(0, 0, 100, 100), bounds=(0, 0, 100, 100),
@ -254,6 +380,56 @@ def test_route_all_restores_best_iteration_snapshot_on_timeout(monkeypatch: pyte
assert results["netA"].outcome == "completed" assert results["netA"].outcome == "completed"
def test_capture_conflict_trace_records_restored_best_stage(monkeypatch: pytest.MonkeyPatch) -> None:
problem = RoutingProblem(
bounds=(0, 0, 100, 100),
nets=(NetSpec("netA", Port(10, 50, 0), Port(90, 50, 0), width=2.0),),
)
options = RoutingOptions(
congestion=CongestionOptions(max_iterations=2, warm_start_enabled=False),
refinement=RefinementOptions(enabled=False),
diagnostics=DiagnosticsOptions(capture_conflict_trace=True),
)
evaluator = CostEvaluator(RoutingWorld(clearance=2.0), DangerMap(bounds=problem.bounds))
pathfinder = PathFinder(AStarContext(evaluator, problem, options))
best_result = RoutingResult(
net_id="netA",
path=(Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),),
reached_target=True,
report=RoutingReport(),
)
worse_result = RoutingResult(net_id="netA", path=(), reached_target=False)
def fake_run_iteration(self, state, iteration, reroute_net_ids, iteration_callback):
_ = self
_ = reroute_net_ids
_ = iteration_callback
if iteration == 0:
state.results = {"netA": best_result}
return _IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids={"netA"},
total_dynamic_collisions=0,
)
state.results = {"netA": worse_result}
return _IterationReview(
conflicting_nets=set(),
conflict_edges=set(),
completed_net_ids=set(),
total_dynamic_collisions=0,
)
monkeypatch.setattr(PathFinder, "_run_iteration", fake_run_iteration)
pathfinder.route_all()
assert [entry.stage for entry in pathfinder.conflict_trace] == [
"restored_best",
"final",
]
restored_entry = pathfinder.conflict_trace[0]
assert restored_entry.nets[0].outcome == "completed"
def test_route_problem_locked_routes_become_static_obstacles() -> None: def test_route_problem_locked_routes_become_static_obstacles() -> None:
locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),) locked = (Straight.generate(Port(10, 50, 0), 80.0, 2.0, dilation=1.0),)
problem = RoutingProblem( problem = RoutingProblem(

View file

@ -2,15 +2,21 @@ import math
import pytest import pytest
from shapely.geometry import Polygon 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.components import Bend90, Straight
from inire.geometry.collision import RoutingWorld from inire.geometry.collision import RoutingWorld
from inire.geometry.primitives import Port from inire.geometry.primitives import Port
from inire.router._astar_types import AStarContext, AStarNode, SearchRunConfig from inire.router._astar_types import AStarContext, AStarNode, SearchRunConfig
from inire.router._astar_admission import add_node from inire.router._astar_admission import add_node
from inire.router._astar_moves import (
_distance_to_bounds_in_heading,
_should_cap_straights_to_bounds,
)
from inire.router._router import PathFinder, _RoutingState
from inire.router._search import route_astar from inire.router._search import route_astar
from inire.router.cost import CostEvaluator from inire.router.cost import CostEvaluator
from inire.router.danger_map import DangerMap from inire.router.danger_map import DangerMap
from inire.seeds import StraightSeed
BOUNDS = (0, -50, 150, 150) BOUNDS = (0, -50, 150, 150)
@ -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 assert basic_evaluator.h_manhattan(Port(0, 0, 0), Port(10, 10, 0)) > 0.0
def test_distance_to_bounds_in_heading_is_directional() -> None:
bounds = (0, 0, 100, 200)
assert _distance_to_bounds_in_heading(Port(20, 30, 0), bounds) == pytest.approx(80.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 90), bounds) == pytest.approx(170.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 180), bounds) == pytest.approx(20.0)
assert _distance_to_bounds_in_heading(Port(20, 30, 270), bounds) == pytest.approx(30.0)
def test_should_cap_straights_to_bounds_only_for_large_no_warm_runs(basic_evaluator: CostEvaluator) -> None:
large_context = AStarContext(
basic_evaluator,
RoutingProblem(
bounds=(0, 0, 1000, 1000),
nets=tuple(
NetSpec(f"net{i}", Port(0, i * 10, 0), Port(10, i * 10, 0), width=2.0)
for i in range(8)
),
),
RoutingOptions(
congestion=CongestionOptions(warm_start_enabled=False),
),
)
small_context = _build_context(basic_evaluator, bounds=BOUNDS)
assert _should_cap_straights_to_bounds(large_context)
assert not _should_cap_straights_to_bounds(small_context)
def test_pair_local_context_clones_live_static_obstacles() -> None:
obstacle = Polygon([(20, -20), (40, -20), (40, 20), (20, 20)])
engine = RoutingWorld(clearance=2.0)
engine.add_static_obstacle(obstacle)
danger_map = DangerMap(bounds=BOUNDS)
danger_map.precompute([obstacle])
evaluator = CostEvaluator(engine, danger_map, bend_penalty=50.0, sbend_penalty=150.0)
finder = PathFinder(
AStarContext(
evaluator,
RoutingProblem(
bounds=BOUNDS,
nets=(
NetSpec("pair_a", Port(0, 0, 0), Port(60, 0, 0), width=2.0),
NetSpec("pair_b", Port(0, 10, 0), Port(60, 10, 0), width=2.0),
),
),
RoutingOptions(),
)
)
state = _RoutingState(
net_specs={
"pair_a": NetSpec("pair_a", Port(0, 0, 0), Port(60, 0, 0), width=2.0),
"pair_b": NetSpec("pair_b", Port(0, 10, 0), Port(60, 10, 0), width=2.0),
},
ordered_net_ids=["pair_a", "pair_b"],
results={},
needs_self_collision_check=set(),
start_time=0.0,
timeout_s=1.0,
initial_paths=None,
accumulated_expanded_nodes=[],
best_results={},
best_completed_nets=-1,
best_conflict_edges=10**9,
best_dynamic_collisions=10**9,
last_conflict_signature=(),
last_conflict_edge_count=0,
repeated_conflict_count=0,
)
local_context = finder._build_pair_local_context(state, {}, ("pair_a", "pair_b"))
assert finder.context.problem.static_obstacles == ()
assert len(local_context.problem.static_obstacles) == 1
assert len(local_context.cost_evaluator.collision_engine._static_obstacles.geometries) == 1
assert next(iter(local_context.problem.static_obstacles)).equals(obstacle)
def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None: def test_route_astar_bend_collision_override_does_not_persist(basic_evaluator: CostEvaluator) -> None:
context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc") context = _build_context(basic_evaluator, bounds=BOUNDS, bend_radii=(10.0,), bend_collision_type="arc")
@ -439,3 +523,144 @@ def test_no_dynamic_paths_skips_congestion_check(basic_evaluator: CostEvaluator)
assert open_set assert open_set
assert context.metrics.total_congestion_check_calls == 0 assert context.metrics.total_congestion_check_calls == 0
assert context.metrics.total_congestion_cache_misses == 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

@ -169,6 +169,67 @@ def test_verify_path_details_returns_conflicting_net_ids() -> None:
assert detail.conflicting_net_ids == ("netB",) assert detail.conflicting_net_ids == ("netB",)
def test_verify_path_details_reports_component_conflicts() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
path_a = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]
path_b = [
Straight.generate(Port(100, 0, 0), 10.0, width=2.0, dilation=1.0),
Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0),
]
engine.add_path(
"netB",
[poly for component in path_b for poly in component.collision_geometry],
dilated_geometry=[poly for component in path_b for poly in component.dilated_collision_geometry],
component_indexes=[0] * len(path_b[0].collision_geometry) + [1] * len(path_b[1].collision_geometry),
)
detail = engine.verify_path_details("netA", path_a, capture_component_conflicts=True)
assert detail.conflicting_net_ids == ("netB",)
assert detail.component_conflicts == ((0, "netB", 1),)
def test_verify_path_details_deduplicates_component_conflicts() -> None:
engine = RoutingWorld(clearance=2.0)
engine.metrics = AStarMetrics()
query_component = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
end_port=Port(22, 0, 0),
length=22.0,
move_type="straight",
move_spec=StraightSeed(22.0),
physical_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
dilated_collision_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
dilated_physical_geometry=[box(0, 0, 10, 10), box(12, 0, 22, 10)],
)
blocker_component = ComponentResult(
start_port=Port(0, 0, 0),
collision_geometry=[box(5, 0, 17, 10)],
end_port=Port(17, 0, 0),
length=12.0,
move_type="straight",
move_spec=StraightSeed(12.0),
physical_geometry=[box(5, 0, 17, 10)],
dilated_collision_geometry=[box(5, 0, 17, 10)],
dilated_physical_geometry=[box(5, 0, 17, 10)],
)
engine.add_path(
"netB",
blocker_component.collision_geometry,
dilated_geometry=blocker_component.dilated_collision_geometry,
component_indexes=[0],
)
detail = engine.verify_path_details("netA", [query_component], capture_component_conflicts=True)
assert detail.conflicting_net_ids == ("netB",)
assert detail.component_conflicts == ((0, "netB", 0),)
def test_remove_path_clears_dynamic_path() -> None: def test_remove_path_clears_dynamic_path() -> None:
engine = RoutingWorld(clearance=2.0) engine = RoutingWorld(clearance=2.0)
path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)] path = [Straight.generate(Port(0, 0, 0), 20.0, width=2.0, dilation=1.0)]

View file

@ -14,7 +14,15 @@ from inire import (
) )
from inire.router._stack import build_routing_stack from inire.router._stack import build_routing_stack
from inire.seeds import Bend90Seed, PathSeed, StraightSeed from inire.seeds import Bend90Seed, PathSeed, StraightSeed
from inire.tests.example_scenarios import SCENARIOS, _build_evaluator, _build_pathfinder, _net_specs, AStarMetrics, snapshot_example_05 from inire.tests.example_scenarios import (
SCENARIOS,
_build_evaluator,
_build_pathfinder,
_net_specs,
AStarMetrics,
snapshot_example_05,
snapshot_example_07_no_warm_start,
)
EXPECTED_OUTCOMES = { EXPECTED_OUTCOMES = {
@ -43,6 +51,14 @@ def test_example_05_avoids_dynamic_tree_rebuilds() -> None:
assert snapshot.metrics.dynamic_tree_rebuilds == 0 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
def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None: def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None:
bounds = (-20, -20, 170, 170) bounds = (-20, -20, 170, 170)
obstacles = ( obstacles = (

View file

@ -19,6 +19,14 @@ def test_snapshot_example_01_exposes_metrics() -> None:
assert snapshot.metrics.score_component_calls >= 0 assert snapshot.metrics.score_component_calls >= 0
assert snapshot.metrics.danger_map_lookup_calls >= 0 assert snapshot.metrics.danger_map_lookup_calls >= 0
assert snapshot.metrics.move_cache_abs_misses >= 0 assert snapshot.metrics.move_cache_abs_misses >= 0
assert snapshot.metrics.guidance_match_moves >= 0
assert snapshot.metrics.guidance_match_moves_straight >= 0
assert snapshot.metrics.guidance_match_moves_bend90 >= 0
assert snapshot.metrics.guidance_match_moves_sbend >= 0
assert snapshot.metrics.guidance_bonus_applied >= 0.0
assert snapshot.metrics.guidance_bonus_applied_straight >= 0.0
assert snapshot.metrics.guidance_bonus_applied_bend90 >= 0.0
assert snapshot.metrics.guidance_bonus_applied_sbend >= 0.0
assert snapshot.metrics.ray_cast_calls >= 0 assert snapshot.metrics.ray_cast_calls >= 0
assert snapshot.metrics.ray_cast_calls_expand_forward >= 0 assert snapshot.metrics.ray_cast_calls_expand_forward >= 0
assert snapshot.metrics.dynamic_tree_rebuilds >= 0 assert snapshot.metrics.dynamic_tree_rebuilds >= 0
@ -36,6 +44,10 @@ def test_snapshot_example_01_exposes_metrics() -> None:
assert snapshot.metrics.congestion_candidate_ids >= 0 assert snapshot.metrics.congestion_candidate_ids >= 0
assert snapshot.metrics.verify_dynamic_candidate_nets >= 0 assert snapshot.metrics.verify_dynamic_candidate_nets >= 0
assert snapshot.metrics.refinement_candidates_verified >= 0 assert snapshot.metrics.refinement_candidates_verified >= 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 0
assert snapshot.metrics.pair_local_search_attempts >= 0
assert snapshot.metrics.pair_local_search_accepts >= 0
assert snapshot.metrics.pair_local_search_nodes_expanded >= 0
def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None: def test_record_performance_baseline_script_writes_selected_scenario(tmp_path: Path) -> None:
@ -195,3 +207,70 @@ def test_diff_performance_baseline_script_renders_current_metrics_for_added_scen
report = output_path.read_text() 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 | duration_s | - |" in report
assert "| example_07_large_scale_routing_no_warm_start | nodes_expanded | - |" 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()

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()