diff --git a/MIGRATION.md b/MIGRATION.md index 2f4765d..e723821 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -349,7 +349,7 @@ class MyTool(Tool): ``` Routing entry points now name every supported route argument explicitly. -Custom per-route planning values must be placed under `tool_options`: +Custom per-route Tool values must be placed under `tool_options`: ```python pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'}) @@ -450,15 +450,25 @@ 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 -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. +`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 +`plan_options={'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 default planner's `strategy` option affects only that +final discovery-order tie-break and is also supplied through `plan_options`. + +`plan_options` is reserved for planner-specific per-route policy, while +`tool_options` is forwarded only to `Tool.primitive_offers()`. For example: + +```python +pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'}) +``` 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/error.py b/masque/builder/error.py index 927c03c..eb2b205 100644 --- a/masque/builder/error.py +++ b/masque/builder/error.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from enum import Enum, auto from pprint import pformat from types import MappingProxyType +import traceback from ..error import BuildError @@ -68,10 +69,11 @@ class RouteFailureDetails: class RouteError(BuildError): - """A route-selection failure with structured request diagnostics.""" + """A route-selection failure with structured request and saved call-stack diagnostics.""" details: RouteFailureDetails policy: RouteFailurePolicy + _call_stack: tuple[traceback.FrameSummary, ...] def __init__( self, @@ -103,4 +105,5 @@ class RouteError(BuildError): ] if details.minimum_cause is not None: lines.append(f' minimum_failure: {details.minimum_cause}') + self._call_stack = tuple(traceback.extract_stack()[:-1]) super().__init__('\n'.join(lines)) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 2968868..c9bd1d4 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -8,9 +8,11 @@ planner package is intentionally internal: custom route generators should extend `Tool.primitive_offers()` and `Tool.render()` rather than depending on planner classes or search details. -Public routing arguments are explicit. Custom per-route planning values belong -in `tool_options`; Pather forwards those values only to offer discovery. A Tool -must capture any selected render-time value in its offer's committed data. +Public routing arguments are explicit. Planner-specific per-route settings +belong in `plan_options`, while custom Tool offer values belong in +`tool_options`. Pather keeps those namespaces separate and forwards Tool +options only to offer discovery. A Tool must capture any selected render-time +value in its offer's committed data. Routing is split into four ownership phases: - snapshot: `Pather` resolves the active Tool for each requested port and @@ -77,11 +79,8 @@ from .planner.interface import ( RoutePortContext, route_failure_policy, ) -from .error import RouteFailurePolicy, ToolContractError -from .planner import ( - RouteTieBreakStrategy, - RoutingPlanner, - ) +from .error import RouteError, RouteFailurePolicy, ToolContractError +from .planner import RoutingPlanner from .planner.bounds import resolved_position_bound from .logging import PatherLogger from ._tolerances import angles_equal, array_close @@ -128,6 +127,20 @@ def _validated_tool_options(tool_options: Mapping[str, Any] | None) -> dict[str, return options +def _validated_plan_options(plan_options: Mapping[str, Any] | None) -> dict[str, Any]: + """Copy generic per-route planner options without interpreting their keys.""" + if plan_options is None: + return {} + try: + options = dict(plan_options) + except (TypeError, ValueError) as err: + raise BuildError('plan_options must be a mapping with string keys') from err + nonstring = [key for key in options if not isinstance(key, str)] + if nonstring: + raise BuildError(f'plan_options keys must be strings; got {nonstring!r}') + return options + + class Pather(PortList): """ A `Pather` is a helper object used for snapping together multiple @@ -626,7 +639,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -652,9 +665,8 @@ class Pather(PortList): For a single port with no length or bound, legal primitive-offer candidates are evaluated at their minimum legal length-like parameters, then cost selects among those minimum-length candidates. `out_ptype`, - when provided, constrains only the final route endpoint. `strategy` - controls straight-first vs turn-first ordering only after cost and - structural tie-breakers. + when provided, constrains only the final route endpoint. Planner-specific + per-route settings belong in `plan_options`. `spacing` and `set_rotation` are only valid when using a bundle bound. """ @@ -672,20 +684,23 @@ class Pather(PortList): ymax=ymax, min_past_furthest=min_past_furthest, ) - options = _validated_tool_options(tool_options) + plan_opts = _validated_plan_options(plan_options) + tool_opts = _validated_tool_options(tool_options) with self._logger.log_operation( self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, - strategy=strategy, tool_options=options, **bounds, + plan_options=plan_opts, tool_options=tool_opts, **bounds, ): if isinstance(portspec, str): portspec = [portspec] contexts = self._route_contexts(portspec) try: result = self.planner.plan_trace_route( - contexts, ccw, length, spacing=spacing, strategy=strategy, - tool_options=options, **bounds, + contexts, ccw, length, spacing=spacing, plan_options=plan_opts, + tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: raise if length is not None and len(contexts) == 1: @@ -726,7 +741,7 @@ class Pather(PortList): *, length: float | None = None, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -755,8 +770,7 @@ class Pather(PortList): With no positional or bundle bound, single-port `trace_to()` uses the same omitted minimum-length primitive-offer behavior as `trace()`. - `strategy` controls straight-first vs turn-first ordering only after - cost and structural tie-breakers. + Planner-specific per-route settings belong in `plan_options`. """ bounds = _present_route_args( length=length, @@ -778,20 +792,23 @@ class Pather(PortList): ymax=ymax, min_past_furthest=min_past_furthest, ) - options = _validated_tool_options(tool_options) + plan_opts = _validated_plan_options(plan_options) + tool_opts = _validated_tool_options(tool_options) with self._logger.log_operation( self, 'trace_to', portspec, ccw=ccw, spacing=spacing, - strategy=strategy, tool_options=options, **bounds, + plan_options=plan_opts, tool_options=tool_opts, **bounds, ): if isinstance(portspec, str): portspec = [portspec] contexts = self._route_contexts(portspec) try: result = self.planner.plan_trace_to_route( - contexts, ccw, spacing=spacing, strategy=strategy, - tool_options=options, **bounds, + contexts, ccw, spacing=spacing, plan_options=plan_opts, + tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if ( not self._dead or len(contexts) != 1 @@ -829,7 +846,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -850,7 +867,7 @@ class Pather(PortList): tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.trace_to( - portspec, None, length=length, spacing=spacing, strategy=strategy, + portspec, None, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, @@ -865,7 +882,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -886,7 +903,7 @@ class Pather(PortList): tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.trace_to( - portspec, ccw, length=length, spacing=spacing, strategy=strategy, + portspec, ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, @@ -900,7 +917,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -921,7 +938,7 @@ class Pather(PortList): tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.bend( - portspec, True, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + portspec, True, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -933,7 +950,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -954,7 +971,7 @@ class Pather(PortList): tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.bend( - portspec, False, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + portspec, False, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -967,7 +984,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, p: float | None = None, pos: float | None = None, @@ -989,25 +1006,27 @@ class Pather(PortList): Multi-port jogs require `spacing`; the innermost first-bend port uses the base `length` or omitted-length solve, and other ports derive exact route lengths and offsets from that base route. `out_ptype`, when - provided, constrains only each final route endpoint. `strategy` - controls straight-first vs S-first ordering only after cost and - structural tie-breakers. + provided, constrains only each final route endpoint. Planner-specific + per-route settings belong in `plan_options`. """ bounds = _present_route_args(out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y) - options = _validated_tool_options(tool_options) + plan_opts = _validated_plan_options(plan_options) + tool_opts = _validated_tool_options(tool_options) with self._logger.log_operation( self, 'jog', portspec, offset=offset, length=length, spacing=spacing, - strategy=strategy, tool_options=options, **bounds, + plan_options=plan_opts, tool_options=tool_opts, **bounds, ): if isinstance(portspec, str): portspec = [portspec] contexts = self._route_contexts(portspec) try: result = self.planner.plan_jog_route( - contexts, offset, length, spacing=spacing, strategy=strategy, - tool_options=options, **bounds, + contexts, offset, length, spacing=spacing, plan_options=plan_opts, + tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if ( not self._dead or len(contexts) != 1 @@ -1051,7 +1070,7 @@ class Pather(PortList): length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, tool_options: Mapping[str, Any] | None = None, ) -> Self: @@ -1066,25 +1085,27 @@ class Pather(PortList): other ports derive exact lengths and offsets from it. Use `length=0` to request the old zero-public-length U-turn shape. Positional and bundle-bound keywords are not supported for this operation. `out_ptype`, - when provided, constrains only each final route endpoint. `strategy` - controls straight-first vs U-first ordering only after cost and - structural tie-breakers. + when provided, constrains only each final route endpoint. Planner-specific + per-route settings belong in `plan_options`. """ bounds = _present_route_args(out_ptype=out_ptype) - options = _validated_tool_options(tool_options) + plan_opts = _validated_plan_options(plan_options) + tool_opts = _validated_tool_options(tool_options) with self._logger.log_operation( self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, - strategy=strategy, tool_options=options, **bounds, + plan_options=plan_opts, tool_options=tool_opts, **bounds, ): if isinstance(portspec, str): portspec = [portspec] contexts = self._route_contexts(portspec) try: result = self.planner.plan_uturn_route( - contexts, offset, length, spacing=spacing, strategy=strategy, - tool_options=options, **bounds, + contexts, offset, length, spacing=spacing, plan_options=plan_opts, + tool_options=tool_opts, **bounds, ) except (BuildError, NotImplementedError) as err: + if isinstance(err, RouteError): + err.__traceback__ = None if ( not self._dead or len(contexts) != 1 @@ -1115,21 +1136,26 @@ class Pather(PortList): out_ptype: str | None = None, plug_destination: bool = True, thru: str | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | 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 `plan_options={'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. The default planner's `strategy` option + therefore affects only otherwise tied candidates. Custom planning options may be supplied through `tool_options`; they are forwarded only to primitive offer generation. @@ -1140,7 +1166,8 @@ class Pather(PortList): mutated; failures during selected-route execution, including primitive commit, plug/thru application, or render, may leave partial output. """ - options = _validated_tool_options(tool_options) + plan_opts = _validated_plan_options(plan_options) + tool_opts = _validated_tool_options(tool_options) with self._logger.log_operation( self, 'trace_into', @@ -1148,19 +1175,23 @@ class Pather(PortList): out_ptype=out_ptype, plug_destination=plug_destination, thru=thru, - strategy=strategy, - tool_options=options, + plan_options=plan_opts, + tool_options=tool_opts, ): - result = self.planner.plan_trace_into( - self._route_context(portspec_src), - portspec_dst, - self.pattern[portspec_dst].copy(), - out_ptype = out_ptype, - plug_destination = plug_destination, - thru = thru, - strategy = strategy, - tool_options = options, - ) + try: + result = self.planner.plan_trace_into( + self._route_context(portspec_src), + portspec_dst, + self.pattern[portspec_dst].copy(), + out_ptype = out_ptype, + plug_destination = plug_destination, + thru = thru, + plan_options = plan_opts, + tool_options = tool_opts, + ) + except RouteError as err: + err.__traceback__ = None + raise self._apply_route_result(result) return self @@ -1376,7 +1407,7 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -1394,7 +1425,7 @@ class PortPather: if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: spacing = self.default_spacing self.pather.trace( - self.ports, ccw, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + self.ports, ccw, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -1407,7 +1438,7 @@ class PortPather: *, length: float | None = None, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -1430,7 +1461,7 @@ class PortPather: if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: spacing = self.default_spacing self.pather.trace_to( - self.ports, ccw, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + self.ports, ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -1442,7 +1473,7 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -1463,7 +1494,7 @@ class PortPather: tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.trace_to( - None, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + None, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -1475,7 +1506,7 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -1496,7 +1527,7 @@ class PortPather: tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.trace_to( - ccw, length=length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + ccw, length=length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -1507,7 +1538,7 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -1528,7 +1559,7 @@ class PortPather: tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.bend( - True, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + True, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -1539,7 +1570,7 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, each: float | None = None, set_rotation: float | None = None, @@ -1560,7 +1591,7 @@ class PortPather: tool_options: Mapping[str, Any] | None = None, ) -> Self: return self.bend( - False, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + False, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, each=each, set_rotation=set_rotation, p=p, pos=pos, position=position, x=x, y=y, emin=emin, emax=emax, pmin=pmin, pmax=pmax, xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, min_past_furthest=min_past_furthest, tool_options=tool_options, @@ -1572,7 +1603,7 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, p: float | None = None, pos: float | None = None, @@ -1584,7 +1615,7 @@ class PortPather: if spacing is None and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0): spacing = self.default_spacing self.pather.jog( - self.ports, offset, length, spacing=spacing, strategy=strategy, out_ptype=out_ptype, + self.ports, offset, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y, tool_options=tool_options, ) return self @@ -1595,14 +1626,14 @@ class PortPather: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, out_ptype: str | None = None, tool_options: Mapping[str, Any] | None = None, ) -> Self: if spacing is None and self.default_spacing is not None and len(self.ports) > 1: spacing = self.default_spacing self.pather.uturn( - self.ports, offset, length, spacing=spacing, strategy=strategy, + self.ports, offset, length, spacing=spacing, plan_options=plan_options, out_ptype=out_ptype, tool_options=tool_options, ) return self @@ -1614,13 +1645,13 @@ class PortPather: out_ptype: str | None = None, plug_destination: bool = True, thru: str | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | 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, plan_options=plan_options, 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..60c6e93 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,56 @@ 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 validate_plan_options( + self, + plan_options: Mapping[str, Any] | None, + *, + allow_bend_policy: bool = False, + ) -> dict[str, Any]: + """Copy and validate the default planner's per-route option mapping.""" + if plan_options is None: + return {} + try: + options = dict(plan_options) + except (TypeError, ValueError) as err: + raise BuildError('plan_options must be a mapping with string keys') from err + nonstring = [key for key in options if not isinstance(key, str)] + if nonstring: + raise BuildError(f'plan_options keys must be strings; got {nonstring!r}') + supported = {'strategy'} + if allow_bend_policy: + supported.add('bend_policy') + unsupported = sorted(options.keys() - supported) + if unsupported: + raise BuildError( + f'RoutingPlanner plan_options contains unsupported keys: {", ".join(unsupported)}' + ) + if options.get('strategy') is not None: + options['strategy'] = validate_strategy(options['strategy']) + if options.get('bend_policy') is not None: + options['bend_policy'] = validate_trace_into_bend_policy(options['bend_policy']) + return options + + 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]] = [] @@ -1286,11 +1352,13 @@ class RoutingPlanner: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan straight or single-bend traces, including `each` and bundle-bound modes.""" + plan_opts = self.validate_plan_options(plan_options) + strategy = plan_opts.get('strategy') route_bounds = dict(bounds) request_details = {'ccw': ccw, **{ key: value for key, value in route_bounds.items() if value is not None @@ -1299,8 +1367,8 @@ class RoutingPlanner: request_details['length'] = length if spacing is not None: request_details['spacing'] = spacing - if strategy is not None: - request_details['strategy'] = strategy + if plan_opts: + request_details['plan_options'] = dict(plan_opts) if tool_options: request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'trace' @@ -1416,19 +1484,21 @@ class RoutingPlanner: ccw: SupportsBool | None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes.""" + plan_opts = self.validate_plan_options(plan_options) + strategy = plan_opts.get('strategy') route_bounds = dict(bounds) request_details = {'ccw': ccw, **{ key: value for key, value in route_bounds.items() if value is not None }} if spacing is not None: request_details['spacing'] = spacing - if strategy is not None: - request_details['strategy'] = strategy + if plan_opts: + request_details['plan_options'] = dict(plan_opts) if tool_options: request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'trace_to' @@ -1504,11 +1574,13 @@ class RoutingPlanner: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan S-bend routes for single ports or spaced bundles.""" + plan_opts = self.validate_plan_options(plan_options) + strategy = plan_opts.get('strategy') offset = planner_bounds.finite_scalar(offset, 'offset') request_details = {'offset': offset, **{ key: value for key, value in bounds.items() if value is not None @@ -1517,8 +1589,8 @@ class RoutingPlanner: request_details['length'] = length if spacing is not None: request_details['spacing'] = spacing - if strategy is not None: - request_details['strategy'] = strategy + if plan_opts: + request_details['plan_options'] = dict(plan_opts) if tool_options: request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'jog' @@ -1580,11 +1652,13 @@ class RoutingPlanner: length: float | None = None, *, spacing: float | ArrayLike | None = None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan U-turn routes for single ports or spaced bundles.""" + plan_opts = self.validate_plan_options(plan_options) + strategy = plan_opts.get('strategy') offset = planner_bounds.finite_scalar(offset, 'offset') route_bounds = dict(bounds) request_details = {'offset': offset, **{ @@ -1594,8 +1668,8 @@ class RoutingPlanner: request_details['length'] = length if spacing is not None: request_details['spacing'] = spacing - if strategy is not None: - request_details['strategy'] = strategy + if plan_opts: + request_details['plan_options'] = dict(plan_opts) if tool_options: request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'uturn' @@ -1722,10 +1796,14 @@ class RoutingPlanner: out_ptype: str | None, plug_destination: bool, thru: str | None, - strategy: RouteTieBreakStrategy | str | None = None, + plan_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None, ) -> PreparedRouteResult: """Plan a bounded route from one source port into a destination port.""" + plan_opts = self.validate_plan_options(plan_options, allow_bend_policy=True) + strategy = plan_opts.get('strategy') + bend_policy = plan_opts.get('bend_policy') + 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 +1812,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 +1821,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 +1829,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_autotool_planning.py b/masque/test/test_autotool_planning.py index 33cbee7..969674b 100644 --- a/masque/test/test_autotool_planning.py +++ b/masque/test/test_autotool_planning.py @@ -996,7 +996,13 @@ def test_autotool_strategy_orders_main_steps_across_adapters(jog: float) -> None for strategy in ('straight_first', 'turn_first'): pather = Pather(library, tools=tool, render='deferred') pather.ports['A'] = Port((0, 0), 0, ptype=primary) - pather.jog('A', jog, length=route_length, out_ptype=primary, strategy=strategy) + pather.jog( + 'A', + jog, + length=route_length, + out_ptype=primary, + plan_options={'strategy': strategy}, + ) selected_kinds[strategy] = [step.kind for step in pather._paths['A']] assert selected_kinds['straight_first'] == ['straight', 'straight', 'straight', 's'] diff --git a/masque/test/test_build_library.py b/masque/test/test_build_library.py index e020912..27393af 100644 --- a/masque/test/test_build_library.py +++ b/masque/test/test_build_library.py @@ -1,8 +1,10 @@ from collections.abc import Iterator +from types import FunctionType +import traceback import pytest -from ..builder import Pather +from ..builder import Pather, PathTool, RouteError from ..error import BuildError, LibraryError from ..library import ( INameView, @@ -28,6 +30,17 @@ def _owned_by(report: BuildReport, owner: str) -> set[str]: } +def _external_failing_route_recipe(lib: ILibrary) -> Pattern: + pather = Pather( + lib, + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=PathTool(layer='M1', width=2, ptype='wire'), + render='deferred', + ) + pather.ccw('A', 10, out_ptype='optical') + return pather.pattern + + class _MetadataSource(ILibraryView): def __init__(self, mapping: dict[str, Pattern], child_graph: dict[str, set[str]]) -> None: self.mapping = mapping @@ -263,6 +276,34 @@ def test_build_library_validate_is_retryable_after_failure() -> None: assert report.dependency_graph["parent"] == frozenset({"child"}) +def test_build_library_error_preserves_route_call_site() -> None: + builder = LibraryBuilder() + external_recipe = FunctionType( + _external_failing_route_recipe.__code__.replace(co_filename='/project/user_recipe.py'), + globals(), + ) + builder.cells.top = cell(external_recipe)(builder.library) + + with pytest.raises(BuildError) as exc_info: + builder.validate() + + message = str(exc_info.value) + assert 'Cause: Unable to plan trace_to route' in message + assert 'Stack trace:' not in message + + route_error = exc_info.value.__cause__ + assert isinstance(route_error, RouteError) + assert route_error.__traceback__ is not None + route_frames = traceback.extract_tb(route_error.__traceback__) + assert sum(frame.filename == '/project/user_recipe.py' for frame in route_frames) == 1 + assert not any('/builder/planner/' in frame.filename for frame in route_frames) + assert route_error.__cause__ is not None + assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack) + + native = ''.join(traceback.format_exception(exc_info.value)) + assert native.count('/project/user_recipe.py') == 1 + + def test_build_library_depends_on_supports_hidden_dependencies_for_partial_validation() -> None: builder = LibraryBuilder() builder["child"] = Pattern() diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py index 4bf5c53..55e1b21 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -60,6 +60,10 @@ def test_route_failure_details_enforces_minimum_status_invariants() -> None: ) +def test_plain_build_error_does_not_include_stacktrace() -> None: + assert str(BuildError('plain builder failure')) == 'plain builder failure' + + def test_builder_init() -> None: lib = Library() b = Pather(lib, name="mypat") diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index 30e3762..58ee5f6 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -1,6 +1,8 @@ from collections.abc import Sequence from typing import Any, Literal, Never +from types import FunctionType import inspect +import traceback import pytest import numpy @@ -34,6 +36,18 @@ class PlanningOnlyTool(Tool): return tree +def _external_failing_ccw(pather: Pather) -> None: + pather.ccw('A', 10, out_ptype='optical') + + +def _external_failing_trace_to(pather: Pather) -> None: + pather.trace_to('A', True, length=10, out_ptype='optical') + + +def _external_failing_portpather_ccw(pather: Pather) -> None: + pather.at('A').ccw(10, out_ptype='optical') + + class FirstPortOnlyTraceTool(PlanningOnlyTool): def __init__(self) -> None: self.render_calls = 0 @@ -270,6 +284,90 @@ def test_route_error_reports_no_route_at_any_length() -> None: assert 'no legal route exists at any length' in str(exc_info.value) +def test_route_error_reports_external_pather_call_site() -> None: + p = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=PathTool(layer='M1', width=2, ptype='wire'), + render='deferred', + ) + external_call = FunctionType( + _external_failing_ccw.__code__.replace(co_filename='/project/user_layout.py'), + globals(), + ) + + with pytest.raises(RouteError) as exc_info: + external_call(p) + + message = str(exc_info.value) + assert 'Stack trace:' not in message + + route_error = exc_info.value + native_frames = traceback.extract_tb(route_error.__traceback__) + assert native_frames + assert [frame.name for frame in native_frames if frame.filename.endswith('/builder/pather.py')] == [ + 'ccw', + 'bend', + ] + assert not any('/builder/planner/' in frame.filename for frame in native_frames) + + native = ''.join(traceback.format_exception(route_error)) + assert native.count('/project/user_layout.py') == 1 + assert '\nStack trace:\n' not in native + assert route_error.__cause__ is not None + + assert isinstance(route_error._call_stack, tuple) + assert sum(frame.filename == '/project/user_layout.py' for frame in route_error._call_stack) == 1 + assert any(frame.filename.endswith('/builder/pather.py') for frame in route_error._call_stack) + assert any('/builder/planner/' in frame.filename for frame in route_error._call_stack) + + +def test_route_error_direct_trace_to_starts_native_traceback_at_caller() -> None: + p = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=PathTool(layer='M1', width=2, ptype='wire'), + render='deferred', + ) + external_call = FunctionType( + _external_failing_trace_to.__code__.replace(co_filename='/project/direct_route.py'), + globals(), + ) + + with pytest.raises(RouteError) as exc_info: + external_call(p) + + frames = traceback.extract_tb(exc_info.value.__traceback__) + assert sum(frame.filename == '/project/direct_route.py' for frame in frames) == 1 + assert not any(frame.filename.endswith('/builder/pather.py') for frame in frames) + assert not any('/builder/planner/' in frame.filename for frame in frames) + + +def test_route_error_portpather_keeps_only_forwarding_frames() -> None: + p = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=PathTool(layer='M1', width=2, ptype='wire'), + render='deferred', + ) + external_call = FunctionType( + _external_failing_portpather_ccw.__code__.replace(co_filename='/project/selected_route.py'), + globals(), + ) + + with pytest.raises(RouteError) as exc_info: + external_call(p) + + frames = traceback.extract_tb(exc_info.value.__traceback__) + assert sum(frame.filename == '/project/selected_route.py' for frame in frames) == 1 + assert [frame.name for frame in frames if frame.filename.endswith('/builder/pather.py')] == [ + 'ccw', + 'bend', + 'trace_to', + ] + assert not any('/builder/planner/' in frame.filename for frame in frames) + + def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: planner = RoutingPlanner() context = RoutePortContext( @@ -986,6 +1084,16 @@ def test_routing_entry_points_have_no_catch_all_kwargs(route_type: type, name: s assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters) +@pytest.mark.parametrize('route_type', [Pather, PortPather]) +@pytest.mark.parametrize('name', ['trace', 'trace_to', 'straight', 'bend', 'ccw', 'cw', 'jog', 'uturn', 'trace_into']) +def test_routing_entry_points_use_generic_plan_options(route_type: type, name: str) -> None: + parameters = inspect.signature(getattr(route_type, name)).parameters + + assert 'plan_options' in parameters + assert 'strategy' not in parameters + assert 'bend_policy' not in parameters + + def test_routing_typo_fails_before_tool_lookup() -> None: tool = RequestCountingTool() p = Pather(Library(), tools=tool, render='deferred') @@ -1007,6 +1115,41 @@ def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, An assert tool.offer_calls == 0 +@pytest.mark.parametrize('plan_options', [7, {1: 'bad'}]) +def test_plan_options_reject_invalid_mapping_shape(plan_options: Any) -> None: + tool = RequestCountingTool() + p = Pather(Library(), tools=tool, render='deferred') + + with pytest.raises(BuildError, match='plan_options'): + p.trace('A', None, plan_options=plan_options) + + assert tool.offer_calls == 0 + + +@pytest.mark.parametrize( + 'plan_options', + [ + {'bend_policy': 'flexible'}, + {'unknown': True}, + ], +) +def test_default_planner_rejects_unsupported_plan_options_before_tool_lookup( + plan_options: dict[str, Any], + ) -> None: + tool = RequestCountingTool() + p = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=tool, + render='deferred', + ) + + with pytest.raises(BuildError, match='unsupported keys'): + p.trace('A', None, length=1, plan_options=plan_options) + + assert tool.offer_calls == 0 + + @pytest.mark.parametrize( 'operation', [ diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py index 93f874c..af068b8 100644 --- a/masque/test/test_pather_core.py +++ b/masque/test/test_pather_core.py @@ -93,6 +93,41 @@ def test_pather_accepts_and_reuses_planner_instance() -> None: assert planner.trace_to_calls == 2 +def test_pather_copies_and_forwards_plan_options_to_planner() -> None: + class RecordingPlanner(RoutingPlanner): + def __init__(self) -> None: + super().__init__() + self.received_plan_options: Any = None + + def plan_trace_to_route( + self, + *args: Any, + plan_options: Any = None, + **kwargs: Any, + ) -> Any: + self.received_plan_options = plan_options + return super().plan_trace_to_route( + *args, + plan_options=plan_options, + **kwargs, + ) + + planner = RecordingPlanner() + p = Pather( + Library(), + tools=PathTool(layer=(1, 0), width=1), + render='deferred', + planner=planner, + ) + p.ports['A'] = Port((0, 0), rotation=0) + supplied = {'strategy': 'turn_first'} + + p.straight('A', 1, plan_options=supplied) + + assert planner.received_plan_options == supplied + assert planner.received_plan_options is not supplied + + def test_port_tool_policy_and_portpather_selection_follow_names() -> None: default_tool = PathTool(layer=(1, 0), width=1, ptype='wire') named_tool = PathTool(layer=(2, 0), width=1, ptype='wire') diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py index 626779c..0a6abe3 100644 --- a/masque/test/test_pather_primitive_offers.py +++ b/masque/test/test_pather_primitive_offers.py @@ -732,7 +732,7 @@ def test_pather_route_strategy_uses_planner_default() -> None: def test_pather_route_strategy_per_route_overrides_planner_default() -> None: pather, _tool = pather_with_strategy_tool(RoutingPlanner(strategy='turn_first')) - pather.jog('A', 4, length=10, strategy='straight_first') + pather.jog('A', 4, length=10, plan_options={'strategy': 'straight_first'}) assert [step.data['kind'] for step in pather._paths['A']] == ['straight', 's'] @@ -740,15 +740,21 @@ def test_pather_route_strategy_per_route_overrides_planner_default() -> None: def test_pather_route_strategy_per_route_can_request_turn_first() -> None: pather, _tool = pather_with_strategy_tool() - pather.jog('A', 4, length=10, strategy='turn_first') + pather.jog('A', 4, length=10, plan_options={'strategy': 'turn_first'}) assert [step.data['kind'] for step in pather._paths['A']] == ['s', 'straight'] -def test_pather_route_strategy_is_not_forwarded_to_tool() -> None: +def test_pather_plan_options_are_not_forwarded_to_tool() -> None: pather, tool = pather_with_strategy_tool() - pather.jog('A', 4, length=10, strategy='turn_first', tool_options={'marker': 'sentinel'}) + pather.jog( + 'A', + 4, + length=10, + plan_options={'strategy': 'turn_first'}, + tool_options={'marker': 'sentinel'}, + ) assert tool.seen_kwargs assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs) @@ -762,7 +768,7 @@ def test_pather_route_strategy_rejects_invalid_values() -> None: pather, _tool = pather_with_strategy_tool() with pytest.raises(BuildError, match='Invalid route strategy'): - pather.jog('A', 4, length=10, strategy='sideways') + pather.jog('A', 4, length=10, plan_options={'strategy': 'sideways'}) def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving() -> None: diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index 48ab125..1e86ded 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -113,6 +113,78 @@ 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, + plan_options={'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, + plan_options={'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 +377,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 +413,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, + plan_options={'bend_policy': 'flexible'}, + ) assert planner.solver.attempts == attempts assert planner.solver_requests == 1 @@ -351,7 +432,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, + plan_options={'bend_policy': 'flexible'}, + ) assert planner.solver.attempts == [(0, 2)] assert planner.solver_requests == 1 @@ -361,8 +450,124 @@ 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, + plan_options={'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', plan_options={'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, + plan_options={'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] + + pather = Pather( + Library(), + tools=PathTool(layer='M1', width=1, ptype='wire'), + 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='Invalid trace_into bend policy'): + pather.trace_into('src', 'dst', plan_options={'bend_policy': 'sideways'})