feat(autoroute): add best-of-N support (attempts, targetNets, passSchedule) (#190)

The existing single-shot autoroute leaves 1-7 nets unrouted on dense
boards in my testing. Best-of-N drives that to 0 most of the time by
running Freerouting a few times with varied --max-passes and keeping
the SES with the best routing score.

New optional parameters (all backward-compatible):

  attempts:     int, default 1 (unchanged behaviour). When > 1, run
                Freerouting N times and pick the highest-scoring SES.
  targetNets:   list of critical net names. An attempt that routes all
                of them earns a 50,000-point scoring bonus.
  passSchedule: list of --max-passes values to cycle through across
                attempts. Default: [50, 60, 65, 70, 75, 80, 85, 90, 55,
                95] (wraps if attempts > len). Ignored when attempts=1
                (legacy maxPasses still used).

Scoring contract (pinned by tests):
  score = nets_routed * 1000 + segments
  if targetNets and all routed: score += 50_000

  - +1 net always beats any segment-count delta (1000 pt step).
  - Segments break ties at equal net count.
  - Target bonus dominates net-count gains from unrelated nets.

## Implementation notes

  - When attempts > 1, each attempt runs with `-mt 1` (single-thread
    optimisation). Freerouting 2.x's multi-threaded optimiser is
    documented to introduce clearance violations, so forcing
    single-thread during scoring keeps the comparison apples-to-apples.
  - One failed attempt does not abort the whole best-of-N run. The
    failure is recorded in the response under attempts[] with ok=False,
    and the remaining attempts compete for best. If every attempt fails
    the response surfaces a clear error.
  - The winning SES is preserved as <stem>_best.ses next to the
    canonical <stem>.ses so the caller can inspect it after the run.
  - Response shape:
      attempts == 1:  unchanged (no attempts/best_attempt fields)
      attempts > 1:   adds attempts[], best_attempt, best_score,
                      best_ses_path

## Attribution

Scoring approach and default pass schedule ported from
morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/routing/freeroute_runner.py). Credited in the function
docstring, the TypeScript wrapper comment, the tool description (visible
to MCP clients), and the CHANGELOG entry.

The MCP version adds: cleaner per-attempt result reporting, automatic
single-thread optimisation, graceful degradation on partial failure,
and explicit validation that surfaces clean error payloads for invalid
attempts values.

## Tests

  tests/test_autoroute_score.py             8 cases, scoring contract
  tests/test_autoroute_best_of_n.py         6 cases, orchestration logic

All 14 passing. Tests are pure-Python: subprocess is mocked so the
suite runs in any environment (no Java / Freerouting / KiCad required).

  - Single-attempt response shape unchanged
  - Best-of-three picks the highest-scoring SES
  - One nonzero exit attempt doesn't abort the run
  - passSchedule wraps when attempts exceeds len
  - targetNets bonus wins over higher raw net count
  - attempts=0 rejected with clean error before DSN export
  - +1 net (1000 pts) dominates any segment delta
  - Segments tiebreak at equal net count
  - Quoted net names in SES are normalised vs unquoted targets

TypeScript builds clean.
This commit is contained in:
NiNjA-CodE
2026-05-18 21:03:38 -06:00
committed by GitHub
parent 983ffc3793
commit e2941631c1
5 changed files with 733 additions and 51 deletions

View File

@@ -0,0 +1,295 @@
"""Integration tests for autoroute's best-of-N loop.
Mocks subprocess + pcbnew so the test verifies the orchestration without
actually invoking Freerouting or KiCad. Specifically:
- attempts=1 preserves single-attempt behaviour (no `attempts`/
`passSchedule` in response, no per-attempt `_best.ses` snapshotting).
- attempts>1 runs N times and imports the highest-scoring SES.
- One failing attempt doesn't abort the whole best-of-N run.
- The pass schedule wraps when attempts > len(schedule).
"""
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
from commands import freerouting as fr_mod # noqa: E402
from commands.freerouting import FreeroutingCommands # noqa: E402
# Synthetic SES text: a `(net ...` section with N (wire ...) inside ->
# scores as N nets, N segments (matching the scorer's expectations).
def _make_ses(num_nets, segs_per_net=2):
chunks = []
for i in range(num_nets):
chunks.append(f'(net "N{i}"\n')
for _ in range(segs_per_net):
chunks.append(" (wire (path F.Cu 200 0 0 1 1))\n")
chunks.append(")\n")
return "".join(chunks)
def _stub_pcbnew(dsn_to_create=None):
"""A minimal pcbnew stub for autoroute's needs.
ExportSpecctraDSN normally writes a file as a side effect; tests pass
a path so the stub creates an empty placeholder file to match the
real-world contract (the `if not os.path.isfile(dsn_path):` guard
inside autoroute).
"""
pcb = MagicMock(name="pcbnew_module")
def _fake_export(_board, dsn_path, *_args, **_kw):
# Write a placeholder DSN so the existence check passes
with open(dsn_path, "w") as f:
f.write("(pcb \"stub\")\n")
return True
pcb.ExportSpecctraDSN.side_effect = _fake_export
pcb.ImportSpecctraSES.return_value = True
return pcb
def _make_cmds(board_path, dsn_exists=True):
"""FreeroutingCommands with a mocked board pointing at board_path."""
cc = FreeroutingCommands.__new__(FreeroutingCommands)
board = MagicMock(name="board")
board.GetFileName.return_value = str(board_path)
board.Save.return_value = None
tracks = MagicMock()
tracks.__iter__ = lambda s: iter([])
board.GetTracks.return_value = tracks
cc.board = board
return cc
@pytest.fixture()
def workdir(tmp_path):
# Pretend we have an existing board file
(tmp_path / "test.kicad_pcb").write_text("(kicad_pcb)\n")
return tmp_path
@pytest.fixture()
def fake_jar(tmp_path):
p = tmp_path / "freerouting.jar"
p.write_text("not a real jar")
return p
def _patch_exec_mode(cc):
"""Force direct (non-Docker) mode so we don't go down the docker branch."""
cc._resolve_execution_mode = MagicMock(return_value={
"mode": "direct", "use_docker": False,
})
@pytest.mark.unit
def test_single_attempt_default_keeps_legacy_response_shape(workdir, fake_jar):
"""attempts=1 (or omitted): no `attempts` list in response."""
cc = _make_cmds(workdir / "test.kicad_pcb")
_patch_exec_mode(cc)
ses_path = workdir / "test.ses"
def fake_run(cmd, **kw):
# Write a one-net SES on each subprocess call
ses_path.write_text(_make_ses(num_nets=5))
proc = types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
return proc
fake_pcb = _stub_pcbnew()
with patch.object(fr_mod, "subprocess") as sp, \
patch.dict(sys.modules, {"pcbnew": fake_pcb}):
sp.run.side_effect = fake_run
sp.TimeoutExpired = TimeoutError
out = cc.autoroute({
"boardPath": str(workdir / "test.kicad_pcb"),
"freeroutingJar": str(fake_jar),
})
assert out["success"], out
assert "attempts" not in out, "single-attempt response must not include attempts list"
assert "best_attempt" not in out
assert sp.run.call_count == 1
@pytest.mark.unit
def test_best_of_three_picks_highest_scoring_ses(workdir, fake_jar):
"""attempts=3 with varying SES content: best one wins."""
cc = _make_cmds(workdir / "test.kicad_pcb")
_patch_exec_mode(cc)
ses_path = workdir / "test.ses"
best_path = workdir / "test_best.ses"
# Per-attempt SES net counts: 5, 8, 7. Best should be attempt 2 (8 nets).
counts = iter([5, 8, 7])
def fake_run(cmd, **kw):
n = next(counts)
ses_path.write_text(_make_ses(num_nets=n))
return types.SimpleNamespace(returncode=0, stdout=f"n={n}", stderr="")
fake_pcb = _stub_pcbnew()
with patch.object(fr_mod, "subprocess") as sp, \
patch.dict(sys.modules, {"pcbnew": fake_pcb}):
sp.run.side_effect = fake_run
sp.TimeoutExpired = TimeoutError
out = cc.autoroute({
"boardPath": str(workdir / "test.kicad_pcb"),
"freeroutingJar": str(fake_jar),
"attempts": 3,
})
assert out["success"], out
assert "attempts" in out
assert len(out["attempts"]) == 3
assert out["best_attempt"] == 2, f"attempt 2 has 8 nets, should win; got {out['best_attempt']}"
assert out["best_score"] == 8 * 1000 + (8 * 2) # 8 nets, 16 segments
# The final ses_path content should match the winning attempt (8 nets)
final_ses = ses_path.read_text()
assert final_ses.count("(net ") == 8
# The _best.ses snapshot must also exist
assert best_path.exists()
@pytest.mark.unit
def test_one_failing_attempt_does_not_abort_best_of_n(workdir, fake_jar):
"""Attempt 2 exits nonzero; best-of-N keeps going and picks among the rest."""
cc = _make_cmds(workdir / "test.kicad_pcb")
_patch_exec_mode(cc)
ses_path = workdir / "test.ses"
call_idx = {"n": 0}
def fake_run(cmd, **kw):
call_idx["n"] += 1
if call_idx["n"] == 2:
# Fail the middle attempt
return types.SimpleNamespace(returncode=99, stdout="", stderr="boom")
# First and third produce 5 and 9 nets respectively
nets = 5 if call_idx["n"] == 1 else 9
ses_path.write_text(_make_ses(num_nets=nets))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
fake_pcb = _stub_pcbnew()
with patch.object(fr_mod, "subprocess") as sp, \
patch.dict(sys.modules, {"pcbnew": fake_pcb}):
sp.run.side_effect = fake_run
sp.TimeoutExpired = TimeoutError
out = cc.autoroute({
"boardPath": str(workdir / "test.kicad_pcb"),
"freeroutingJar": str(fake_jar),
"attempts": 3,
})
assert out["success"], out
assert sp.run.call_count == 3
assert out["best_attempt"] == 3, "attempt 3 (9 nets) must win over attempt 1 (5 nets)"
# The middle attempt is recorded but marked not-ok
middle = next(a for a in out["attempts"] if a["attempt"] == 2)
assert middle["ok"] is False
assert middle["exit_code"] == 99
@pytest.mark.unit
def test_pass_schedule_wraps_when_attempts_exceeds_schedule_length(workdir, fake_jar):
"""Custom 2-entry schedule + 5 attempts → schedule cycles [a, b, a, b, a]."""
cc = _make_cmds(workdir / "test.kicad_pcb")
_patch_exec_mode(cc)
ses_path = workdir / "test.ses"
seen_mp = []
def fake_run(cmd, **kw):
# extract `-mp N` from the command
mp = int(cmd[cmd.index("-mp") + 1])
seen_mp.append(mp)
ses_path.write_text(_make_ses(num_nets=3))
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
fake_pcb = _stub_pcbnew()
with patch.object(fr_mod, "subprocess") as sp, \
patch.dict(sys.modules, {"pcbnew": fake_pcb}):
sp.run.side_effect = fake_run
sp.TimeoutExpired = TimeoutError
out = cc.autoroute({
"boardPath": str(workdir / "test.kicad_pcb"),
"freeroutingJar": str(fake_jar),
"attempts": 5,
"passSchedule": [42, 99],
})
assert out["success"], out
assert seen_mp == [42, 99, 42, 99, 42]
@pytest.mark.unit
def test_target_nets_bonus_wins_against_more_nets_without_targets(workdir, fake_jar):
"""Attempt with all targets beats higher-net attempt missing one target."""
cc = _make_cmds(workdir / "test.kicad_pcb")
_patch_exec_mode(cc)
ses_path = workdir / "test.ses"
# Attempt 1: 8 nets, none named "CRIT" — misses target
# Attempt 2: 4 nets including "CRIT" — hits target, gets 50k bonus
def fake_run(cmd, **kw):
attempt = sum(1 for c in cmd if c == "-mp") # rough counter — patched below
ses_path.write_text(fake_run.next_ses)
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
sess = [
_make_ses(num_nets=8),
# 4 nets but one named CRIT
'(net "N0"\n (wire (path F.Cu 200 0 0 1 1))\n)\n'
'(net "N1"\n (wire (path F.Cu 200 0 0 1 1))\n)\n'
'(net "N2"\n (wire (path F.Cu 200 0 0 1 1))\n)\n'
'(net "CRIT"\n (wire (path F.Cu 200 0 0 1 1))\n)\n',
]
seq = iter(sess)
def fake_run(cmd, **kw):
fake_run.next_ses = next(seq)
ses_path.write_text(fake_run.next_ses)
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
fake_pcb = _stub_pcbnew()
with patch.object(fr_mod, "subprocess") as sp, \
patch.dict(sys.modules, {"pcbnew": fake_pcb}):
sp.run.side_effect = fake_run
sp.TimeoutExpired = TimeoutError
out = cc.autoroute({
"boardPath": str(workdir / "test.kicad_pcb"),
"freeroutingJar": str(fake_jar),
"attempts": 2,
"targetNets": ["CRIT"],
})
assert out["success"], out
assert out["best_attempt"] == 2, (
"attempt 2 routed the critical target — its 50k bonus must beat "
"attempt 1's higher raw net count"
)
@pytest.mark.unit
def test_invalid_attempts_rejected_cleanly(workdir, fake_jar):
cc = _make_cmds(workdir / "test.kicad_pcb")
_patch_exec_mode(cc)
out = cc.autoroute({
"boardPath": str(workdir / "test.kicad_pcb"),
"freeroutingJar": str(fake_jar),
"attempts": 0,
})
assert out["success"] is False
assert "Invalid attempts" in out["message"]

View File

@@ -0,0 +1,122 @@
"""Tests for the best-of-N scoring in autoroute (`_score_ses`).
Best-of-N support is ported from morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/routing/freeroute_runner.py::score_ses). These tests pin the
scoring contract so future changes don't silently shift the ranking.
"""
import sys
from pathlib import Path
import pytest
PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))
from commands.freerouting import _score_ses # noqa: E402
def _ses(nets, segments, vias=0):
"""Build a minimal SES text with the given (net, segment_count) shape.
The parser only looks at `(net NAME\n (wire` occurrences and `(wire`
/ `(via ` substrings, so a synthetic fixture is enough.
"""
chunks = []
for net, seg_count in nets:
chunks.append(f'(net "{net}"\n')
for _ in range(seg_count):
chunks.append(" (wire (path F.Cu 200 0 0 1 1))\n")
chunks.append(")\n")
# Tack on extra wires not tied to a (net ...) header — counted as
# segments but not as nets.
for _ in range(segments - sum(s for _, s in nets)):
chunks.append(" (wire (path F.Cu 200 0 0 1 1))\n")
for _ in range(vias):
chunks.append(' (via "Via[0-1]_600:300_um" 100 200)\n')
return "".join(chunks)
@pytest.mark.unit
def test_empty_ses_scores_zero():
r = _score_ses("", [])
assert r["score"] == 0
assert r["nets"] == 0
assert r["segments"] == 0
@pytest.mark.unit
def test_more_nets_always_beats_more_segments():
"""A single extra net (1000 pts) must beat any reasonable seg count delta."""
a = _score_ses(_ses([("N1", 50), ("N2", 50), ("N3", 50)], segments=150), [])
b = _score_ses(_ses([("N1", 50), ("N2", 50)], segments=999), [])
assert a["nets"] == 3 and b["nets"] == 2
assert a["score"] > b["score"], "+1 net (1000 pts) must dominate segment delta"
@pytest.mark.unit
def test_segments_break_ties_when_net_counts_equal():
a = _score_ses(_ses([("N1", 10), ("N2", 10)], segments=20), [])
b = _score_ses(_ses([("N1", 30), ("N2", 30)], segments=60), [])
assert a["nets"] == b["nets"] == 2
assert b["score"] > a["score"], "more segments breaks tie when net counts equal"
@pytest.mark.unit
def test_target_bonus_dominates_when_all_targets_routed():
"""The 50,000-point bonus must outweigh marginal nets/segments differences."""
# `with_targets` has the targets but fewer overall nets.
with_targets = _score_ses(
_ses([("CRITICAL_A", 10), ("CRITICAL_B", 10)], segments=20),
target_nets=["CRITICAL_A", "CRITICAL_B"],
)
# `without_targets` has more nets but is missing one target.
without_targets = _score_ses(
_ses([
("CRITICAL_A", 10),
("OTHER1", 10), ("OTHER2", 10), ("OTHER3", 10),
("OTHER4", 10), ("OTHER5", 10), ("OTHER6", 10),
], segments=70),
target_nets=["CRITICAL_A", "CRITICAL_B"],
)
assert without_targets["targets_missing"] == ["CRITICAL_B"]
assert with_targets["targets_missing"] == []
assert with_targets["score"] > without_targets["score"], (
"all-targets bonus must beat marginal net-count gain"
)
@pytest.mark.unit
def test_target_bonus_inactive_when_targets_unspecified():
"""No targets configured -> score == nets*1000 + segments (no bonus)."""
r = _score_ses(_ses([("X", 5)], segments=5), [])
assert r["score"] == 1000 + 5
assert r["targets_found"] == [] and r["targets_missing"] == []
@pytest.mark.unit
def test_quoted_net_names_in_ses_are_normalised():
"""Freerouting writes nets as `(net "X"` — surrounding quotes must be stripped."""
text = '(net "MY_NET"\n (wire (path F.Cu 200 0 0 1 1))\n)\n'
r = _score_ses(text, target_nets=["MY_NET"])
assert r["targets_missing"] == [], "quoted net name should match unquoted target"
assert r["targets_found"] == ["MY_NET"]
@pytest.mark.unit
def test_targets_missing_and_found_are_sorted_for_stable_output():
text = _ses(
[("Z_NET", 1), ("A_NET", 1), ("M_NET", 1)],
segments=3,
)
r = _score_ses(text, target_nets=["Z_NET", "A_NET", "MISSING_X", "MISSING_A"])
assert r["targets_found"] == ["A_NET", "Z_NET"]
assert r["targets_missing"] == ["MISSING_A", "MISSING_X"]
@pytest.mark.unit
def test_via_count_is_reported_independently_of_score():
r = _score_ses(_ses([("X", 2)], segments=2, vias=7), [])
assert r["vias"] == 7
# Score formula does NOT include vias by design.
assert r["score"] == 1 * 1000 + 2