more performance characterization work

This commit is contained in:
Jan Petykiewicz 2026-04-02 15:58:03 -07:00
commit 2049353ee9
9 changed files with 2423 additions and 64 deletions

View file

@ -272,7 +272,7 @@ Separately from the observational trace tooling, the router may run a bounded po
Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`. Lower-level search and collision modules are semi-private implementation details. They remain accessible through deep imports for advanced use, but they are unstable and may change without notice. The stable supported entrypoint is `route(problem, options=...)`.
The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**. The current implementation structure is summarized in **[docs/architecture.md](docs/architecture.md)**. The committed example-corpus counter baseline is tracked in **[docs/performance.md](docs/performance.md)**.
Use `scripts/diff_performance_baseline.py` to compare a fresh local run against that baseline. Use `scripts/record_conflict_trace.py` for opt-in conflict-hotspot traces 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. Use `scripts/diff_performance_baseline.py` to compare a fresh local run against that baseline. Use `scripts/record_conflict_trace.py` for opt-in conflict-hotspot traces, `scripts/record_frontier_trace.py` for hotspot-adjacent prune traces, and `scripts/characterize_pair_local_search.py` to sweep example_07-style no-warm runs for pair-local repair behavior. The counter baseline is currently observational and is not enforced as a CI gate.
## 11. Tuning Notes ## 11. Tuning Notes

File diff suppressed because it is too large Load diff

View file

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

View file

@ -7,6 +7,9 @@ Use `scripts/diff_performance_baseline.py` to compare a fresh run against that s
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. The default baseline table below covers the standard example corpus only. The heavier `example_07_large_scale_routing_no_warm_start` canary remains performance-only and is tracked through targeted diffs plus the conflict/frontier trace artifacts.
Use `scripts/characterize_pair_local_search.py` when you want a small parameter sweep over example_07-style no-warm runs instead of a single canary reading.
The current tracked sweep output lives in `docs/pair_local_characterization.json` and `docs/pair_local_characterization.md`.
| Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls | | Scenario | Duration (s) | Total | Valid | Reached | Iter | Nets Routed | Nodes | Ray Casts | Moves Gen | Moves Added | Dyn Tree | Visibility Builds | Congestion Checks | Verify Calls |
| :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | | :-- | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: | --: |
| example_01_simple_route | 0.0040 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 4 | | example_01_simple_route | 0.0040 | 1 | 1 | 1 | 1 | 1 | 2 | 10 | 11 | 7 | 0 | 0 | 0 | 4 |
@ -31,6 +34,8 @@ For the current accepted branch, the most important performance-only canary is `
- `pair_local_search_accepts` - `pair_local_search_accepts`
- `pair_local_search_nodes_expanded` - `pair_local_search_nodes_expanded`
The latest tracked characterization sweep confirms there is no smaller stable pair-local smoke case under the `<=1.0s` rule, so the 10-net no-warm-start canary remains the primary regression target for this behavior.
Tracked metric keys: Tracked metric keys:
nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, iteration_reverify_calls, iteration_reverified_nets, iteration_conflicting_nets, iteration_conflict_edges, nets_carried_forward, score_component_calls, score_component_total_ns, path_cost_calls, danger_map_lookup_calls, danger_map_cache_hits, danger_map_cache_misses, danger_map_query_calls, danger_map_total_ns, move_cache_abs_hits, move_cache_abs_misses, move_cache_rel_hits, move_cache_rel_misses, guidance_match_moves, guidance_match_moves_straight, guidance_match_moves_bend90, guidance_match_moves_sbend, guidance_bonus_applied, guidance_bonus_applied_straight, guidance_bonus_applied_bend90, guidance_bonus_applied_sbend, static_safe_cache_hits, hard_collision_cache_hits, congestion_cache_hits, congestion_cache_misses, congestion_presence_cache_hits, congestion_presence_cache_misses, congestion_presence_skips, congestion_candidate_precheck_hits, congestion_candidate_precheck_misses, congestion_candidate_precheck_skips, congestion_grid_net_cache_hits, congestion_grid_net_cache_misses, congestion_grid_span_cache_hits, congestion_grid_span_cache_misses, congestion_candidate_nets, congestion_net_envelope_cache_hits, congestion_net_envelope_cache_misses, dynamic_path_objects_added, dynamic_path_objects_removed, dynamic_tree_rebuilds, dynamic_grid_rebuilds, static_tree_rebuilds, static_raw_tree_rebuilds, static_net_tree_rebuilds, visibility_corner_index_builds, visibility_builds, visibility_corner_pairs_checked, visibility_corner_queries_exact, visibility_corner_hits_exact, visibility_point_queries, visibility_point_cache_hits, visibility_point_cache_misses, visibility_tangent_candidate_scans, visibility_tangent_candidate_corner_checks, visibility_tangent_candidate_ray_tests, ray_cast_calls, ray_cast_calls_straight_static, ray_cast_calls_expand_snap, ray_cast_calls_expand_forward, ray_cast_calls_visibility_build, ray_cast_calls_visibility_query, ray_cast_calls_visibility_tangent, ray_cast_calls_other, ray_cast_candidate_bounds, ray_cast_exact_geometry_checks, congestion_check_calls, congestion_lazy_resolutions, congestion_lazy_requeues, congestion_candidate_ids, congestion_exact_pair_checks, verify_path_report_calls, verify_static_buffer_ops, verify_dynamic_candidate_nets, verify_dynamic_exact_pair_checks, refinement_windows_considered, refinement_static_bounds_checked, refinement_dynamic_bounds_checked, refinement_candidate_side_extents, refinement_candidates_built, refinement_candidates_verified, refinement_candidates_accepted, pair_local_search_pairs_considered, pair_local_search_attempts, pair_local_search_accepts, pair_local_search_nodes_expanded nodes_expanded, moves_generated, moves_added, pruned_closed_set, pruned_hard_collision, pruned_cost, route_iterations, nets_routed, nets_reached_target, warm_start_paths_built, warm_start_paths_used, refine_path_calls, timeout_events, 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

@ -432,17 +432,19 @@ def trace_example_07_no_warm_start() -> RoutingRunResult:
return _trace_example_07_variant(warm_start_enabled=False) return _trace_example_07_variant(warm_start_enabled=False)
def _snapshot_example_07_variant( def _build_example_07_variant_stack(
name: str,
*, *,
num_nets: int,
seed: int,
warm_start_enabled: bool, warm_start_enabled: bool,
) -> ScenarioSnapshot: capture_conflict_trace: bool = False,
capture_frontier_trace: bool = False,
) -> tuple[CostEvaluator, AStarMetrics, PathFinder]:
bounds = (0, 0, 1000, 1000) bounds = (0, 0, 1000, 1000)
obstacles = [ obstacles = [
box(450, 0, 550, 400), box(450, 0, 550, 400),
box(450, 600, 550, 1000), box(450, 600, 550, 1000),
] ]
num_nets = 10
start_x = 50 start_x = 50
start_y_base = 500 - (num_nets * 10.0) / 2.0 start_y_base = 500 - (num_nets * 10.0) / 2.0
end_x = 950 end_x = 950
@ -477,74 +479,30 @@ def _snapshot_example_07_variant(
"multiplier": 1.4, "multiplier": 1.4,
"net_order": "shortest", "net_order": "shortest",
"capture_expanded": True, "capture_expanded": True,
"capture_conflict_trace": capture_conflict_trace,
"capture_frontier_trace": capture_frontier_trace,
"shuffle_nets": True, "shuffle_nets": True,
"seed": 42, "seed": seed,
"warm_start_enabled": warm_start_enabled, "warm_start_enabled": warm_start_enabled,
}, },
) )
return evaluator, metrics, pathfinder
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()
t0 = perf_counter()
results = pathfinder.route_all(iteration_callback=iteration_callback)
t1 = perf_counter()
return _make_snapshot(name, results, t1 - t0, pathfinder.metrics.snapshot())
def _trace_example_07_variant( def _run_example_07_variant(
*, *,
num_nets: int,
seed: int,
warm_start_enabled: bool, warm_start_enabled: bool,
capture_conflict_trace: bool = False,
capture_frontier_trace: bool = False,
) -> RoutingRunResult: ) -> RoutingRunResult:
bounds = (0, 0, 1000, 1000) evaluator, metrics, pathfinder = _build_example_07_variant_stack(
obstacles = [ num_nets=num_nets,
box(450, 0, 550, 400), seed=seed,
box(450, 600, 550, 1000), warm_start_enabled=warm_start_enabled,
] capture_conflict_trace=capture_conflict_trace,
num_nets = 10 capture_frontier_trace=capture_frontier_trace,
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: def iteration_callback(idx: int, current_results: dict[str, RoutingResult]) -> None:
@ -557,6 +515,34 @@ def _trace_example_07_variant(
return _make_run_result(results, pathfinder) return _make_run_result(results, pathfinder)
def _snapshot_example_07_variant(
name: str,
*,
warm_start_enabled: bool,
) -> ScenarioSnapshot:
t0 = perf_counter()
run = _run_example_07_variant(
num_nets=10,
seed=42,
warm_start_enabled=warm_start_enabled,
)
t1 = perf_counter()
return _make_snapshot(name, run.results_by_net, t1 - t0, run.metrics)
def _trace_example_07_variant(
*,
warm_start_enabled: bool,
) -> RoutingRunResult:
return _run_example_07_variant(
num_nets=10,
seed=42,
warm_start_enabled=warm_start_enabled,
capture_conflict_trace=True,
capture_frontier_trace=True,
)
def run_example_07() -> ScenarioOutcome: def run_example_07() -> ScenarioOutcome:
return snapshot_example_07().as_outcome() return snapshot_example_07().as_outcome()

View file

@ -15,7 +15,7 @@ if TYPE_CHECKING:
RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1" RUN_PERFORMANCE = os.environ.get("INIRE_RUN_PERFORMANCE") == "1"
PERFORMANCE_REPEATS = 3 PERFORMANCE_REPEATS = 3
REGRESSION_FACTOR = 1.5 REGRESSION_FACTOR = 1.5
NO_WARM_START_REGRESSION_SECONDS = 180.0 NO_WARM_START_REGRESSION_SECONDS = 15.0
# Baselines are measured from clean 6a28dcf-style runs without plotting. # Baselines are measured from clean 6a28dcf-style runs without plotting.
BASELINE_SECONDS = { BASELINE_SECONDS = {

View file

@ -22,6 +22,7 @@ from inire.tests.example_scenarios import (
AStarMetrics, AStarMetrics,
snapshot_example_05, snapshot_example_05,
snapshot_example_07_no_warm_start, snapshot_example_07_no_warm_start,
trace_example_07_no_warm_start,
) )
@ -57,6 +58,28 @@ def test_example_07_no_warm_start_canary_improves_validity() -> None:
assert snapshot.total_results == 10 assert snapshot.total_results == 10
assert snapshot.reached_targets == 10 assert snapshot.reached_targets == 10
assert snapshot.valid_results == 10 assert snapshot.valid_results == 10
assert snapshot.metrics.warm_start_paths_built == 0
assert snapshot.metrics.warm_start_paths_used == 0
assert snapshot.metrics.pair_local_search_pairs_considered >= 1
assert snapshot.metrics.pair_local_search_accepts >= 1
assert snapshot.metrics.pair_local_search_nodes_expanded <= 128
assert snapshot.metrics.nodes_expanded <= 2500
assert snapshot.metrics.congestion_check_calls <= 6000
def test_example_07_no_warm_start_trace_finishes_without_conflict_edges() -> None:
run = trace_example_07_no_warm_start()
assert len(run.results_by_net) == 10
assert sum(result.is_valid for result in run.results_by_net.values()) == 10
assert sum(result.reached_target for result in run.results_by_net.values()) == 10
assert run.metrics.pair_local_search_pairs_considered >= 1
assert run.metrics.pair_local_search_accepts >= 1
final_entry = run.conflict_trace[-1]
assert final_entry.stage == "final"
assert len(final_entry.completed_net_ids) == 10
assert final_entry.conflict_edges == ()
def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None: def test_example_06_clipped_bbox_margin_restores_legacy_seed() -> None:

View file

@ -274,3 +274,33 @@ def test_record_frontier_trace_script_writes_selected_scenario(tmp_path: Path) -
assert payload["generator"] == "scripts/record_frontier_trace.py" assert payload["generator"] == "scripts/record_frontier_trace.py"
assert [entry["name"] for entry in payload["scenarios"]] == ["example_05_orientation_stress"] assert [entry["name"] for entry in payload["scenarios"]] == ["example_05_orientation_stress"]
assert (tmp_path / "frontier_trace.md").exists() assert (tmp_path / "frontier_trace.md").exists()
def test_characterize_pair_local_search_script_writes_outputs(tmp_path: Path) -> None:
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "characterize_pair_local_search.py"
subprocess.run(
[
sys.executable,
str(script_path),
"--output-dir",
str(tmp_path),
"--num-nets",
"6",
"--seeds",
"41",
"--repeats",
"1",
],
check=True,
)
payload = json.loads((tmp_path / "pair_local_characterization.json").read_text())
assert payload["generated_at"]
assert payload["generator"] == "scripts/characterize_pair_local_search.py"
assert payload["grid"]["num_nets"] == [6]
assert payload["grid"]["seeds"] == [41]
assert payload["grid"]["repeats"] == 1
assert len(payload["cases"]) == 1
assert (tmp_path / "pair_local_characterization.md").exists()

View file

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