[pather / planner] add bend_policy, default to error on non-minimal number of bends

This commit is contained in:
Jan Petykiewicz 2026-08-27 10:34:35 -07:00
commit 91a5d63b57
5 changed files with 267 additions and 25 deletions

View file

@ -450,15 +450,17 @@ Stable imports for custom tool authors live in `masque.builder`. The
`masque.builder.planner` module is an internal planner implementation; do not `masque.builder.planner` module is an internal planner implementation; do not
import it from user code. import it from user code.
`trace_into()` uses the same primitive-offer route selection and now searches `trace_into()` uses the same primitive-offer route selection and defaults to
bounded route topologies with up to four bend roles. This preserves the common the minimal main-route bend count required by the endpoint relationship: zero
straight, bend, S-like, U-like, and dogleg cases while allowing routes that for a straight, one for a quarter-turn, and two for S- and U-like connections.
need an additional bounded bend pair. Bend-family requests search one-bend Ptype adapters do not consume this bend budget. Set `bend_policy='flexible'`
to search bounded route topologies with up to four bend roles, including
dogleg and loop-like fallbacks. Bend-family requests then search one-bend
routes before three-bend routes; other families search zero-to-two-bend routes routes before three-bend routes; other families search zero-to-two-bend routes
before four-bend routes. The first band with a legal route wins. Within that before four-bend routes. The first band with a legal route wins. Within that
band, candidates are ordered by total primitive-offer cost, adapter count, step band, candidates are ordered by total primitive-offer cost, adapter count,
count, and deterministic discovery order. The route `strategy` affects only step count, and deterministic discovery order. The route `strategy` affects
that final discovery-order tie-break. only that final discovery-order tie-break.
Explicit-length `jog()` routes may also be satisfied by composing a straight Explicit-length `jog()` routes may also be satisfied by composing a straight
primitive before or after an omitted-length native S primitive. `uturn()` routes primitive before or after an omitted-length native S primitive. `uturn()` routes

View file

@ -80,6 +80,7 @@ from .planner.interface import (
from .error import RouteFailurePolicy, ToolContractError from .error import RouteFailurePolicy, ToolContractError
from .planner import ( from .planner import (
RouteTieBreakStrategy, RouteTieBreakStrategy,
TraceIntoBendPolicy,
RoutingPlanner, RoutingPlanner,
) )
from .planner.bounds import resolved_position_bound from .planner.bounds import resolved_position_bound
@ -1116,20 +1117,26 @@ class Pather(PortList):
plug_destination: bool = True, plug_destination: bool = True,
thru: str | None = None, thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None, strategy: RouteTieBreakStrategy | str | None = None,
bend_policy: TraceIntoBendPolicy | str | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
""" """
Route one port into another using a bounded primitive-offer selection. Route one port into another using a bounded primitive-offer selection.
The current baseline searches bounded primitive-offer routes with up to By default, searches only the exact main-route bend count required by
four bend roles, including straight, single-bend, S-like, U-like, and the endpoint relationship: zero for a straight, one for a quarter-turn,
dogleg topologies. Bend-family requests try one-bend routes before and two for S- and U-like connections. This rejects extra dogleg and
three-bend routes; other families try zero-to-two-bend routes before loop-like fallback routes without inspecting primitive geometry. Ptype
four-bend routes. The first band with a legal candidate wins. Within a adapters do not consume this bend budget.
band, candidates are ordered by total cost, adapter count, step count,
the requested straight-vs-turn topology preference, and deterministic Set `bend_policy='flexible'` to search bounded primitive-offer routes
discovery order. `strategy` therefore affects only otherwise tied with up to four bend roles. Bend-family requests try one-bend routes
candidates. before three-bend routes; other families try zero-to-two-bend routes
before four-bend routes. The first band with a legal candidate wins.
Within a band, candidates are ordered by total cost, adapter count,
step count, the requested straight-vs-turn topology preference, and
deterministic discovery order. `strategy` therefore affects only
otherwise tied candidates.
Custom planning options may be supplied through `tool_options`; they Custom planning options may be supplied through `tool_options`; they
are forwarded only to primitive offer generation. are forwarded only to primitive offer generation.
@ -1149,6 +1156,7 @@ class Pather(PortList):
plug_destination=plug_destination, plug_destination=plug_destination,
thru=thru, thru=thru,
strategy=strategy, strategy=strategy,
bend_policy=bend_policy,
tool_options=options, tool_options=options,
): ):
result = self.planner.plan_trace_into( result = self.planner.plan_trace_into(
@ -1159,6 +1167,7 @@ class Pather(PortList):
plug_destination = plug_destination, plug_destination = plug_destination,
thru = thru, thru = thru,
strategy = strategy, strategy = strategy,
bend_policy = bend_policy,
tool_options = options, tool_options = options,
) )
self._apply_route_result(result) self._apply_route_result(result)
@ -1615,12 +1624,13 @@ class PortPather:
plug_destination: bool = True, plug_destination: bool = True,
thru: str | None = None, thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None, strategy: RouteTieBreakStrategy | str | None = None,
bend_policy: TraceIntoBendPolicy | str | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
port = self._single_port('trace_into') port = self._single_port('trace_into')
self.pather.trace_into( self.pather.trace_into(
port, target_port, out_ptype=out_ptype, plug_destination=plug_destination, port, target_port, out_ptype=out_ptype, plug_destination=plug_destination,
thru=thru, strategy=strategy, tool_options=tool_options, thru=thru, strategy=strategy, bend_policy=bend_policy, tool_options=tool_options,
) )
return self return self

View file

@ -13,4 +13,5 @@ from .interface import (
route_failure_policy as route_failure_policy, route_failure_policy as route_failure_policy,
) )
from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy
from .planner import TraceIntoBendPolicy as TraceIntoBendPolicy
from .planner import RoutingPlanner as RoutingPlanner from .planner import RoutingPlanner as RoutingPlanner

View file

@ -72,6 +72,7 @@ from .interface import (
) )
RouteTieBreakStrategy = Literal['straight_first', 'turn_first'] RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
TraceIntoBendPolicy = Literal['flexible', 'minimal']
COST_RTOL = 1e-10 COST_RTOL = 1e-10
COST_ATOL = 1e-8 COST_ATOL = 1e-8
@ -87,6 +88,17 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr
raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first') raise BuildError(f'Invalid route strategy {strategy!r}; expected straight_first or turn_first')
def validate_trace_into_bend_policy(
bend_policy: TraceIntoBendPolicy | str,
) -> TraceIntoBendPolicy:
"""Return a supported trace-into bend policy or raise a routing error."""
if bend_policy in ('flexible', 'minimal'):
return bend_policy
raise BuildError(
f'Invalid trace_into bend policy {bend_policy!r}; expected flexible or minimal'
)
def is_close(a: float, b: float) -> bool: def is_close(a: float, b: float) -> bool:
"""Compare route-solver scalars with the planner tolerance.""" """Compare route-solver scalars with the planner tolerance."""
return scalar_close(a, b) return scalar_close(a, b)
@ -1057,9 +1069,16 @@ class RoutingPlanner:
TRACE_INTO_MAX_BENDS: int = 4 TRACE_INTO_MAX_BENDS: int = 4
DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first' DEFAULT_STRATEGY: RouteTieBreakStrategy = 'straight_first'
DEFAULT_TRACE_INTO_BEND_POLICY: TraceIntoBendPolicy = 'minimal'
def __init__(self, strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY) -> None: def __init__(
self,
strategy: RouteTieBreakStrategy = DEFAULT_STRATEGY,
*,
bend_policy: TraceIntoBendPolicy = DEFAULT_TRACE_INTO_BEND_POLICY,
) -> None:
self.strategy = validate_strategy(strategy) self.strategy = validate_strategy(strategy)
self.bend_policy = validate_trace_into_bend_policy(bend_policy)
def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy: def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy:
"""Return the per-route strategy or the planner default.""" """Return the per-route strategy or the planner default."""
@ -1067,9 +1086,26 @@ class RoutingPlanner:
return getattr(self, 'strategy', self.DEFAULT_STRATEGY) return getattr(self, 'strategy', self.DEFAULT_STRATEGY)
return validate_strategy(strategy) return validate_strategy(strategy)
def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]: def resolve_trace_into_bend_policy(
"""Return non-overlapping trace_into bend-budget bands for staged solving.""" self,
bend_policy: TraceIntoBendPolicy | str | None,
) -> TraceIntoBendPolicy:
"""Return the per-route trace-into bend policy or the planner default."""
if bend_policy is None:
return getattr(self, 'bend_policy', self.DEFAULT_TRACE_INTO_BEND_POLICY)
return validate_trace_into_bend_policy(bend_policy)
def trace_into_bend_bands(
self,
family: PrimitiveKind,
*,
bend_policy: TraceIntoBendPolicy | str | None = None,
) -> tuple[tuple[int, int], ...]:
"""Return trace_into bend-budget bands for the requested detour policy."""
max_bends = self.TRACE_INTO_MAX_BENDS max_bends = self.TRACE_INTO_MAX_BENDS
if self.resolve_trace_into_bend_policy(bend_policy) == 'minimal':
required_bends = 0 if family == 'straight' else 1 if family == 'bend' else 2
return ((required_bends, required_bends),) if required_bends <= max_bends else ()
if family == 'bend': if family == 'bend':
return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends) return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends)
bands: list[tuple[int, int]] = [] bands: list[tuple[int, int]] = []
@ -1723,9 +1759,11 @@ class RoutingPlanner:
plug_destination: bool, plug_destination: bool,
thru: str | None, thru: str | None,
strategy: RouteTieBreakStrategy | str | None = None, strategy: RouteTieBreakStrategy | str | None = None,
bend_policy: TraceIntoBendPolicy | str | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> PreparedRouteResult: ) -> PreparedRouteResult:
"""Plan a bounded route from one source port into a destination port.""" """Plan a bounded route from one source port into a destination port."""
resolved_bend_policy = self.resolve_trace_into_bend_policy(bend_policy)
if out_ptype is None: if out_ptype is None:
out_ptype = port_dst.ptype out_ptype = port_dst.ptype
if context_src.port.rotation is None or port_dst.rotation is None: if context_src.port.rotation is None or port_dst.rotation is None:
@ -1734,6 +1772,8 @@ class RoutingPlanner:
desired.rotation = port_dst.rotation - pi desired.rotation = port_dst.rotation - pi
desired.ptype = out_ptype desired.ptype = out_ptype
family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) family, length, jog, ccw = self.trace_into_spec(context_src.port, desired)
bend_bands = self.trace_into_bend_bands(family, bend_policy=resolved_bend_policy)
max_bends = max((band[1] for band in bend_bands), default=0)
request = self.solver_request( request = self.solver_request(
family, family,
context_src, context_src,
@ -1741,7 +1781,7 @@ class RoutingPlanner:
jog=jog, jog=jog,
ccw=ccw, ccw=ccw,
constrain_jog=family == 'bend', constrain_jog=family == 'bend',
max_bends=self.TRACE_INTO_MAX_BENDS, max_bends=max_bends,
strategy=strategy, strategy=strategy,
tool_options=tool_options, tool_options=tool_options,
out_ptype=out_ptype, out_ptype=out_ptype,
@ -1749,7 +1789,7 @@ class RoutingPlanner:
solver = self.solver_for_request(request) solver = self.solver_for_request(request)
candidate = None candidate = None
last_error: Exception | None = None last_error: Exception | None = None
for min_bends, max_bends in self.trace_into_bend_bands(family): for min_bends, max_bends in bend_bands:
try: try:
candidate = solver.solve(min_bends=min_bends, max_bends=max_bends) candidate = solver.solve(min_bends=min_bends, max_bends=max_bends)
break break

View file

@ -113,6 +113,73 @@ def test_pather_trace_into_shapes() -> None:
assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2) assert numpy.isclose(p.pattern.ports['I'].rotation, pi / 2)
@pytest.mark.parametrize(
'dst',
[
Port((-10_000, 0), rotation=pi),
Port((-10_000, 2_000), rotation=pi),
Port((-5_000, 5_000), rotation=pi / 2),
Port((-10_000, 2_000), rotation=0),
],
)
def test_pather_trace_into_minimal_policy_accepts_required_topologies(dst: Port) -> None:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1_000),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0)
pather.ports['dst'] = dst
pather.trace_into('src', 'dst', plug_destination=False, bend_policy='minimal')
assert numpy.allclose(pather.ports['src'].offset, dst.offset)
assert pather.ports['src'].rotation is not None
assert numpy.isclose((pather.ports['src'].rotation - dst.rotation) % (2 * pi), pi)
def test_pather_trace_into_bend_policy_changes_real_solver_fallback() -> None:
def make_pather() -> Pather:
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=2, ptype='wire'),
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((2, 0), rotation=pi, ptype='wire')
return pather
flexible = make_pather()
flexible.at('src').trace_into(
'dst',
plug_destination=False,
bend_policy='flexible',
)
assert_equal(flexible.ports['src'].offset, (2, 0))
assert flexible.ports['src'].rotation is not None
assert numpy.isclose(flexible.ports['src'].rotation, 0)
bend_roles = sum(
1 if step.kind == 'bend' else 2 if step.kind in ('s', 'u') else 0
for step in flexible._paths['src']
)
assert bend_roles == 4
minimal = make_pather()
with pytest.raises(BuildError):
minimal.at('src').trace_into(
'dst',
plug_destination=False,
)
assert set(minimal.ports) == {'src', 'dst'}
assert_equal(minimal.ports['src'].offset, (0, 0))
assert numpy.isclose(minimal.ports['src'].rotation, 0)
assert_equal(minimal.ports['dst'].offset, (2, 0))
assert numpy.isclose(minimal.ports['dst'].rotation, pi)
assert not minimal._paths
def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None: def test_pather_trace_into_large_composed_manhattan_route_plugs() -> None:
p = Pather( p = Pather(
Library(), Library(),
@ -305,6 +372,7 @@ class TraceIntoBudgetSolver:
class TraceIntoBudgetPlanner(RoutingPlanner): class TraceIntoBudgetPlanner(RoutingPlanner):
def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None: def __init__(self, successes: set[tuple[int, int]], fatal_at: set[tuple[int, int]] | None = None) -> None:
super().__init__()
self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at) self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
self.solver_requests = 0 self.solver_requests = 0
@ -340,7 +408,15 @@ def test_trace_into_reuses_solver_across_staged_bend_bands(
planner = TraceIntoBudgetPlanner(successes) planner = TraceIntoBudgetPlanner(successes)
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
planner.plan_trace_into(context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None) planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
bend_policy='flexible',
)
assert planner.solver.attempts == attempts assert planner.solver.attempts == attempts
assert planner.solver_requests == 1 assert planner.solver_requests == 1
@ -351,7 +427,15 @@ def test_trace_into_staged_bend_budget_stops_on_fatal_error() -> None:
context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire')) context = RoutePortContext('src', Port((0, 0), rotation=0, ptype='wire'), PathTool(layer='M1', width=1, ptype='wire'))
with pytest.raises(RoutePlanningError, match='fatal'): with pytest.raises(RoutePlanningError, match='fatal'):
planner.plan_trace_into(context, 'dst', Port((-10, 0), rotation=pi, ptype='wire'), out_ptype=None, plug_destination=True, thru=None) planner.plan_trace_into(
context,
'dst',
Port((-10, 0), rotation=pi, ptype='wire'),
out_ptype=None,
plug_destination=True,
thru=None,
bend_policy='flexible',
)
assert planner.solver.attempts == [(0, 2)] assert planner.solver.attempts == [(0, 2)]
assert planner.solver_requests == 1 assert planner.solver_requests == 1
@ -361,8 +445,113 @@ def test_trace_into_bend_bands_respect_max_bends() -> None:
class OneBendPlanner(RoutingPlanner): class OneBendPlanner(RoutingPlanner):
TRACE_INTO_MAX_BENDS = 1 TRACE_INTO_MAX_BENDS = 1
planner = OneBendPlanner() planner = OneBendPlanner(bend_policy='flexible')
assert planner.trace_into_bend_bands('straight') == ((0, 0),) assert planner.trace_into_bend_bands('straight') == ((0, 0),)
assert planner.trace_into_bend_bands('s') == ((0, 0),) assert planner.trace_into_bend_bands('s') == ((0, 0),)
assert planner.trace_into_bend_bands('bend') == ((1, 1),) assert planner.trace_into_bend_bands('bend') == ((1, 1),)
@pytest.mark.parametrize(
('family', 'expected'),
[
('straight', ((0, 0),)),
('bend', ((1, 1),)),
('s', ((2, 2),)),
('u', ((2, 2),)),
],
)
def test_trace_into_default_minimal_bend_bands(family: str, expected: tuple[tuple[int, int], ...]) -> None:
planner = RoutingPlanner()
assert planner.trace_into_bend_bands(family) == expected
assert planner.trace_into_bend_bands(family, bend_policy='flexible') == (
((1, 1), (3, 3)) if family == 'bend' else ((0, 2), (4, 4))
)
@pytest.mark.parametrize(
('dst', 'required_band'),
[
(Port((-10, 0), rotation=pi, ptype='wire'), (0, 0)),
(Port((-10, -5), rotation=pi, ptype='wire'), (2, 2)),
(Port((-10, -10), rotation=3 * pi / 2, ptype='wire'), (1, 1)),
(Port((-10, -5), rotation=0, ptype='wire'), (2, 2)),
],
)
def test_trace_into_minimal_policy_uses_orientation_required_band(
dst: Port,
required_band: tuple[int, int],
) -> None:
planner = TraceIntoBudgetPlanner({required_band})
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
bend_policy='minimal',
)
assert planner.solver.attempts == [required_band]
def test_trace_into_minimal_policy_rejects_fallback_without_mutation() -> None:
planner = TraceIntoBudgetPlanner({(4, 4)})
pather = Pather(
Library(),
tools=PathTool(layer='M1', width=1, ptype='wire'),
planner=planner,
render='deferred',
)
pather.ports['src'] = Port((0, 0), rotation=0, ptype='wire')
pather.ports['dst'] = Port((-10, 0), rotation=pi, ptype='wire')
with pytest.raises(BuildError, match='try next budget'):
pather.trace_into('src', 'dst', bend_policy='minimal')
assert planner.solver.attempts == [(0, 0)]
assert set(pather.ports) == {'src', 'dst'}
assert_equal(pather.ports['src'].offset, (0, 0))
assert_equal(pather.ports['dst'].offset, (-10, 0))
assert not pather._paths
def test_trace_into_bend_policy_planner_default_and_route_override() -> None:
context = RoutePortContext(
'src',
Port((0, 0), rotation=0, ptype='wire'),
PathTool(layer='M1', width=1, ptype='wire'),
)
dst = Port((-10, 0), rotation=pi, ptype='wire')
minimal_planner = TraceIntoBudgetPlanner({(4, 4)})
with pytest.raises(BuildError, match='try next budget'):
minimal_planner.plan_trace_into(
context, 'dst', dst, out_ptype=None, plug_destination=True, thru=None,
)
assert minimal_planner.solver.attempts == [(0, 0)]
flexible_planner = TraceIntoBudgetPlanner({(4, 4)})
flexible_planner.plan_trace_into(
context,
'dst',
dst,
out_ptype=None,
plug_destination=True,
thru=None,
bend_policy='flexible',
)
assert flexible_planner.solver.attempts == [(0, 2), (4, 4)]
def test_trace_into_rejects_invalid_bend_policy() -> None:
with pytest.raises(BuildError, match='Invalid trace_into bend policy'):
RoutingPlanner(bend_policy='sideways') # type: ignore[arg-type]