Fix multi-pin wire/owner desync in shove-in-rooms recovery

_shove_recover_room clobbered a net's _Accepted with a fresh one whose
wire_owners held only the just-recovered wire, while its wires list (the
shared result.wires[net_no]) already held earlier committed connections.
That left wire_owners shorter than the parallel wires list, so a later
net's shove either removed the wrong wire's copper from the search tree
(a silent different-net short: copper stayed in the emitted SES but
vanished from the tree, and has_violation falsely certified it clean) or
raised IndexError and aborted the whole route.

Reuse the net's existing _Accepted on recovery so wire_owners stays
parallel to wires. Fixes both the silent DRC violation and the crash
sibling without touching the DRC-clean or determinism invariants.

Also correct the overstated "density win" framing of the shove_channel
fixture: its plain-routing net drop is an artifact of greedy net order,
not a density limit (the same board routes both nets with zero shoves
under a reorder or with the cosmetic seals removed). Reframed the test
and docstring; the genuine sub-cell win remains the wide_door occupancy
packing (shoves == 0).

Regression tests assert the invariant against the emitted output
directly (reconstructed copper tiles), not just the tree that the bug
fooled, plus determinism, no-crash, and the ordering-artifact bound.
This commit is contained in:
2026-07-13 11:08:02 -06:00
parent a06adbee13
commit 9cf2392c4b
4 changed files with 276 additions and 13 deletions
+129 -9
View File
@@ -7,11 +7,14 @@ Two opt-in (``shove=True``) upgrades to the continuous expansion-room track:
decomposition already keeps every door clearance-clear of committed copper, so
the projection provably reduces to the old clamp where nothing else crosses the
door — the four foundation fixtures stay byte-for-byte identical.
* **Shove-in-rooms** is the real continuous, sub-cell win the grid could not do:
when a net would be dropped, the committed trace blocking it is nudged aside
with the exact shove primitive so *both* fit through the same channel. The
headline fixture ``rooms_shove_channel`` drops a net without it and routes both
with it, DRC-clean.
* **Shove-in-rooms** nudges a committed trace aside with the exact shove
primitive when a net would otherwise be dropped, so *both* fit through the same
channel. ``rooms_shove_channel`` drops a net under the default greedy net order
and routes both with shove, DRC-clean -- a recovery from greedy ordering (the
same board is plainly routable under a reorder or without its cosmetic seals,
see ``test_room_shove_channel_drop_is_ordering_artifact_not_density``), not a
genuine density limit. The true sub-cell win is occupancy packing, which the
``rooms_wide_door`` fixture exercises with ``shoves == 0``.
"""
from __future__ import annotations
@@ -24,10 +27,35 @@ import pytest
from freeroute.board import build_board
from freeroute.dsn import parse_dsn
from freeroute.dsn.sexp import parse
from freeroute.geometry import IntBox, IntPoint
from freeroute.geometry import IntBox, IntPoint, Polyline, PolylineShape
from freeroute.route import route, route_dsn_board_exact, route_dsn_board_rooms
from freeroute.route.pipeline import _rule_clearance_dsn
def _emitted_drc_violations(result, clearance):
"""Independent DRC over the *emitted* route: reconstruct every wire's copper
tiles and return the different-net, same-layer pairs that come closer than
``clearance``. This does NOT consult the search tree, so it catches copper
that is present in the output but (via a bug) absent from the tree -- exactly
the failure mode the wire/owner desync produced."""
items = [] # (net_no, layer, tile)
for net_no, segments in result.wires.items():
for layer, corners in segments:
for box in PolylineShape(Polyline(corners), result.half_width).tiles():
items.append((net_no, layer, box))
bad = []
for i in range(len(items)):
n1, l1, b1 = items[i]
expanded = b1.offset(clearance)
for j in range(i + 1, len(items)):
n2, l2, b2 = items[j]
if n1 == n2 or l1 != l2:
continue
if b2.overlaps(expanded):
bad.append((n1, n2))
return bad
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
FIXTURES = Path(__file__).resolve().parent.parent / "dsn" / "fixtures"
@@ -37,6 +65,8 @@ KICAD = FIXTURES / "kicad_routable.dsn"
NARROW = FIXTURES / "narrow_channel.dsn"
SHOVE_CHANNEL = FIXTURES / "rooms_shove_channel.dsn"
WIDE_DOOR = FIXTURES / "rooms_wide_door.dsn"
MULTIPIN_DESYNC = FIXTURES / "rooms_multipin_desync.dsn"
MULTIPIN_DESYNC_CRASH = FIXTURES / "rooms_multipin_desync_crash.dsn"
FOUNDATION = [SIMPLE, CROSSING, KICAD, NARROW]
@@ -61,15 +91,15 @@ def _pins_by_net(fixture):
return out
# --- the headline density win -----------------------------------------------
# --- shove recovers a greedy-ordering drop ----------------------------------
def test_room_shove_channel_density_win():
def test_room_shove_channel_recovers_greedy_ordering_drop():
dsn = parse_dsn(SHOVE_CHANNEL.read_text())
off, _, _ = route_dsn_board_rooms(dsn, shove=False)
on, scale, _ = route_dsn_board_rooms(dsn, shove=True)
# without shove NET_A's sealed wall leaves no top<->bottom door -> NET_C drops
# under the default greedy net order NET_A (net 1) walls the channel -> NET_C drops
assert off.routed_net_numbers == {1}
assert off.drc_clean
# with shove NET_A is nudged aside and both nets fit through the same channel
@@ -79,6 +109,42 @@ def test_room_shove_channel_density_win():
assert on.tree.has_violation(_clearance(dsn, scale)) is None
def test_room_shove_channel_drop_is_ordering_artifact_not_density():
"""Honest bound on the shove_channel claim: the plain drop is an artifact of
the greedy net order, not a genuine density limit. The identical plain engine
routes BOTH nets with zero shoves if NET_C is listed first, or if the two
cosmetic edge seals are removed -- so shove here recovers a self-inflicted
ordering drop rather than overcoming an unroutable density. (The genuine
sub-cell win is occupancy packing, exercised by the wide_door fixture with
shoves == 0.)"""
orig = SHOVE_CHANNEL.read_text()
# (1) reorder so NET_C becomes net 1 -> plain routing solves it, no shove
swapped = orig.replace(
" (net NET_A\n (pins A1-1 A2-1)\n )\n"
" (net NET_C\n (pins C1-1 C2-1)\n )",
" (net NET_C\n (pins C1-1 C2-1)\n )\n"
" (net NET_A\n (pins A1-1 A2-1)\n )",
)
assert swapped != orig
reordered, _, _ = route_dsn_board_rooms(parse_dsn(swapped), shove=False)
assert reordered.routed_net_numbers == {1, 2}
assert reordered.shoves == 0
assert reordered.drc_clean
# (2) drop the seal keepouts -> plain routing solves it, no shove
noseal = orig.replace(
' (keepout "seal_left"\n (rect F.Cu 0 -30000 2500 0)\n )\n'
' (keepout "seal_right"\n (rect F.Cu 197500 -30000 200000 0)\n )\n',
"",
)
assert noseal != orig
unsealed, _, _ = route_dsn_board_rooms(parse_dsn(noseal), shove=False)
assert unsealed.routed_net_numbers == {1, 2}
assert unsealed.shoves == 0
assert unsealed.drc_clean
def test_room_shove_channel_moves_blocker_but_keeps_its_pads():
on, _, _ = route_dsn_board_rooms(parse_dsn(SHOVE_CHANNEL.read_text()), shove=True)
pins = _pins_by_net(SHOVE_CHANNEL)
@@ -226,3 +292,57 @@ def test_free_intervals_splits_around_forbidden_band():
assert _best_free_point(0, 200, [(87, 113)], ideal=100) == 87
# no forbidden band -> the whole span, clamped to the ideal (old clamp)
assert _best_free_point(0, 200, [], ideal=100) == 100
# --- regression: multi-pin wire/owner desync in shove recovery ---------------
def test_room_shove_multipin_recovery_keeps_output_drc_clean():
"""Regression for the wire/owner desync in ``_shove_recover_room``.
A 3-pin net commits one connection via ``_commit`` and has a second straight
connection *recovered* by the shove path. The recovery used to clobber the
net's ``_Accepted`` with a fresh one whose ``wire_owners`` held only the
just-recovered wire, leaving it SHORTER than the parallel ``wires`` list. A
later net's shove then removed the WRONG wire's copper from the search tree;
that copper stayed in the emitted route but vanished from the tree, so
``has_violation`` falsely certified clean while a following net was placed on
top of it -- a hard different-net short in the SES.
Assert the invariant against the *emitted* output directly, not just the tree
(the tree was the thing being fooled)."""
dsn = parse_dsn(MULTIPIN_DESYNC.read_text())
on, scale, _ = route_dsn_board_rooms(dsn, shove=True)
clearance = _clearance(dsn, scale)
# the tree self-check and the independent emitted-copper check must agree
assert on.drc_clean
assert on.tree.has_violation(clearance) is None
assert _emitted_drc_violations(on.result, clearance) == []
# the shove path actually engaged and did not silently drop the multi-pin net
assert on.shoves >= 1
assert 3 in on.routed_net_numbers
def test_room_shove_multipin_recovery_is_deterministic():
a, _, _ = route_dsn_board_rooms(parse_dsn(MULTIPIN_DESYNC.read_text()), shove=True)
b, _, _ = route_dsn_board_rooms(parse_dsn(MULTIPIN_DESYNC.read_text()), shove=True)
assert _wire_key(a.result) == _wire_key(b.result)
assert a.routed_net_numbers == b.routed_net_numbers
def test_room_shove_multipin_recovery_does_not_crash():
"""Sibling of the desync: when the conflicting wire index exceeds the
(formerly truncated) ``wire_owners`` length, the recovery raised
``IndexError`` and aborted the whole public route. Keeping ``wire_owners``
parallel to ``wires`` fixes both faces; this board must route to completion,
DRC-clean, through the public ``route(engine="room", shove=True)`` API."""
ses = route(MULTIPIN_DESYNC_CRASH.read_text(), engine="room", shove=True)
top = parse(ses)
assert top.head == "session"
# and the library path stays DRC-clean on the emitted output
dsn = parse_dsn(MULTIPIN_DESYNC_CRASH.read_text())
on, scale, _ = route_dsn_board_rooms(dsn, shove=True)
clearance = _clearance(dsn, scale)
assert on.drc_clean
assert _emitted_drc_violations(on.result, clearance) == []