diff --git a/MIGRATION.md b/MIGRATION.md index 2f4765d..70d4953 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 2968868..6f31349 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -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 diff --git a/masque/builder/planner/__init__.py b/masque/builder/planner/__init__.py index 41c53d6..752ac8a 100644 --- a/masque/builder/planner/__init__.py +++ b/masque/builder/planner/__init__.py @@ -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 diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 9fd5a87..3279df6 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -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 diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index 48ab125..a2ce595 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -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]