[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
import it from user code.
`trace_into()` uses the same primitive-offer route selection and now searches
bounded route topologies with up to four bend roles. This preserves the common
straight, bend, S-like, U-like, and dogleg cases while allowing routes that
need an additional bounded bend pair. Bend-family requests search one-bend
`trace_into()` uses the same primitive-offer route selection and defaults to
the minimal main-route bend count required by the endpoint relationship: zero
for a straight, one for a quarter-turn, and two for S- and U-like connections.
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
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
count, and deterministic discovery order. The route `strategy` affects only
that final discovery-order tie-break.
band, candidates are ordered by total primitive-offer cost, adapter count,
step count, and deterministic discovery order. The route `strategy` affects
only that final discovery-order tie-break.
Explicit-length `jog()` routes may also be satisfied by composing a straight
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 .planner import (
RouteTieBreakStrategy,
TraceIntoBendPolicy,
RoutingPlanner,
)
from .planner.bounds import resolved_position_bound
@ -1116,20 +1117,26 @@ class Pather(PortList):
plug_destination: bool = True,
thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
bend_policy: TraceIntoBendPolicy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
"""
Route one port into another using a bounded primitive-offer selection.
The current baseline searches bounded primitive-offer routes with up to
four bend roles, including straight, single-bend, S-like, U-like, and
dogleg topologies. Bend-family requests try one-bend routes 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.
By default, searches only the exact main-route bend count required by
the endpoint relationship: zero for a straight, one for a quarter-turn,
and two for S- and U-like connections. This rejects extra dogleg and
loop-like fallback routes without inspecting primitive geometry. Ptype
adapters do not consume this bend budget.
Set `bend_policy='flexible'` to search bounded primitive-offer routes
with up to four bend roles. Bend-family requests try one-bend routes
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
are forwarded only to primitive offer generation.
@ -1149,6 +1156,7 @@ class Pather(PortList):
plug_destination=plug_destination,
thru=thru,
strategy=strategy,
bend_policy=bend_policy,
tool_options=options,
):
result = self.planner.plan_trace_into(
@ -1159,6 +1167,7 @@ class Pather(PortList):
plug_destination = plug_destination,
thru = thru,
strategy = strategy,
bend_policy = bend_policy,
tool_options = options,
)
self._apply_route_result(result)
@ -1615,12 +1624,13 @@ class PortPather:
plug_destination: bool = True,
thru: str | None = None,
strategy: RouteTieBreakStrategy | str | None = None,
bend_policy: TraceIntoBendPolicy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> Self:
port = self._single_port('trace_into')
self.pather.trace_into(
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

View file

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

View file

@ -72,6 +72,7 @@ from .interface import (
)
RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
TraceIntoBendPolicy = Literal['flexible', 'minimal']
COST_RTOL = 1e-10
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')
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:
"""Compare route-solver scalars with the planner tolerance."""
return scalar_close(a, b)
@ -1057,9 +1069,16 @@ class RoutingPlanner:
TRACE_INTO_MAX_BENDS: int = 4
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.bend_policy = validate_trace_into_bend_policy(bend_policy)
def resolve_strategy(self, strategy: RouteTieBreakStrategy | str | None) -> RouteTieBreakStrategy:
"""Return the per-route strategy or the planner default."""
@ -1067,9 +1086,26 @@ class RoutingPlanner:
return getattr(self, 'strategy', self.DEFAULT_STRATEGY)
return validate_strategy(strategy)
def trace_into_bend_bands(self, family: PrimitiveKind) -> tuple[tuple[int, int], ...]:
"""Return non-overlapping trace_into bend-budget bands for staged solving."""
def resolve_trace_into_bend_policy(
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
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':
return tuple(band for band in ((1, 1), (3, 3)) if band[1] <= max_bends)
bands: list[tuple[int, int]] = []
@ -1723,9 +1759,11 @@ class RoutingPlanner:
plug_destination: bool,
thru: str | None,
strategy: RouteTieBreakStrategy | str | None = None,
bend_policy: TraceIntoBendPolicy | str | None = None,
tool_options: Mapping[str, Any] | None = None,
) -> PreparedRouteResult:
"""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:
out_ptype = port_dst.ptype
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.ptype = out_ptype
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(
family,
context_src,
@ -1741,7 +1781,7 @@ class RoutingPlanner:
jog=jog,
ccw=ccw,
constrain_jog=family == 'bend',
max_bends=self.TRACE_INTO_MAX_BENDS,
max_bends=max_bends,
strategy=strategy,
tool_options=tool_options,
out_ptype=out_ptype,
@ -1749,7 +1789,7 @@ class RoutingPlanner:
solver = self.solver_for_request(request)
candidate = 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:
candidate = solver.solve(min_bends=min_bends, max_bends=max_bends)
break

View file

@ -113,6 +113,73 @@ def test_pather_trace_into_shapes() -> None:
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:
p = Pather(
Library(),
@ -305,6 +372,7 @@ class TraceIntoBudgetSolver:
class TraceIntoBudgetPlanner(RoutingPlanner):
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_requests = 0
@ -340,7 +408,15 @@ def test_trace_into_reuses_solver_across_staged_bend_bands(
planner = TraceIntoBudgetPlanner(successes)
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_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'))
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_requests == 1
@ -361,8 +445,113 @@ def test_trace_into_bend_bands_respect_max_bends() -> None:
class OneBendPlanner(RoutingPlanner):
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('s') == ((0, 0),)
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]