From 67afee7704d266ad0f02148c1775ab3d7ce4cd47 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 12 Jul 2026 20:32:59 -0700 Subject: [PATCH] [Pather / AutoTool] rework route kwargs into tool_options, and actualyl pass them through AutoTool --- MIGRATION.md | 46 +- examples/tutorial/pather.py | 5 +- masque/builder/pather.py | 586 ++++++++++++++++++-- masque/builder/planner/bounds.py | 27 +- masque/builder/planner/planner.py | 63 ++- masque/builder/tools.py | 69 ++- masque/builder/utils.py | 8 +- masque/test/test_autotool_planning.py | 90 ++- masque/test/test_pather_constraints.py | 80 ++- masque/test/test_pather_primitive_offers.py | 18 +- masque/test/test_pather_trace_into.py | 3 +- 11 files changed, 865 insertions(+), 130 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 2d94de9..fdda9e5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -22,13 +22,13 @@ The biggest migration point is that the old routing verbs were renamed: | `Pather.pathU(...)` | `Pather.uturn(...)` | | `Pather.path_into(...)` | `Pather.trace_into(...)` | | `Pather.path_from(src, dst)` | `Pather.at(src).trace_into(dst)` | -| `RenderPather.path(...)` | `Pather(..., auto_render=False).trace(...)` | -| `RenderPather.path_to(...)` | `Pather(..., auto_render=False).trace_to(...)` | -| `RenderPather.mpath(...)` | `Pather(..., auto_render=False).trace(...)` / `Pather(..., auto_render=False).trace_to(...)` | -| `RenderPather.pathS(...)` | `Pather(..., auto_render=False).jog(...)` | -| `RenderPather.pathU(...)` | `Pather(..., auto_render=False).uturn(...)` | -| `RenderPather.path_into(...)` | `Pather(..., auto_render=False).trace_into(...)` | -| `RenderPather.path_from(src, dst)` | `Pather(..., auto_render=False).at(src).trace_into(dst)` | +| `RenderPather.path(...)` | `Pather(..., render='deferred').trace(...)` | +| `RenderPather.path_to(...)` | `Pather(..., render='deferred').trace_to(...)` | +| `RenderPather.mpath(...)` | `Pather(..., render='deferred').trace(...)` / `Pather(..., render='deferred').trace_to(...)` | +| `RenderPather.pathS(...)` | `Pather(..., render='deferred').jog(...)` | +| `RenderPather.pathU(...)` | `Pather(..., render='deferred').uturn(...)` | +| `RenderPather.path_into(...)` | `Pather(..., render='deferred').trace_into(...)` | +| `RenderPather.path_from(src, dst)` | `Pather(..., render='deferred').at(src).trace_into(dst)` | There are also new convenience wrappers: @@ -123,13 +123,13 @@ from masque.builder.renderpather import RenderPather from masque.builder import Pather builder = Pather(...) -deferred = Pather(..., auto_render=False) +deferred = Pather(..., render='deferred') ``` Top-level imports from `masque` also continue to work. -`Pather` now defaults to `auto_render=True`, so plain construction replaces the -old `Builder` behavior. Use `Pather(..., auto_render=False)` where you +`Pather` now defaults to `render='auto'`, so plain construction replaces the +old `Builder` behavior. Use `Pather(..., render='deferred')` where you previously used `RenderPather`. ## `BasicTool` was replaced @@ -218,7 +218,7 @@ class MyTool(Tool): commit_planner=commit, ),) - def render(self, batch: Sequence[RenderStep], **kwargs: Any): + def render(self, batch: Sequence[RenderStep]): ... ``` @@ -281,10 +281,32 @@ class MyTool(Tool): commit_planner=commit, ),) - def render(self, batch: Sequence[RenderStep], **kwargs): + def render(self, batch: Sequence[RenderStep]): ... ``` +Routing entry points now name every supported route argument explicitly. +Custom per-route planning values must be placed under `tool_options`: + +```python +pather.jog('A', 4, length=10, tool_options={'process_corner': 'slow'}) +``` + +The mapping is unpacked only for `Tool.primitive_offers()`. Route arguments do +not leak into that namespace, and tool options are not forwarded to +`Tool.render()`. An offer that needs route-specific render behavior must capture +the selected value in its `commit()` result (`RenderStep.data`). `AutoTool` +does this automatically for generated straight and S-bend primitives: the flat +mapping is snapshotted per selected primitive and passed to its generator as +keyword arguments during rendering. AutoTool options must not change generated +ports or endpoint geometry. `PathTool` defines no tool options and rejects +nonempty mappings. + +AutoTool does not validate generator keyword signatures during planning. A bad +keyword therefore raises when the generator runs, normally during rendering. +Generators that require route options must provide explicit port metadata at +registration; S-bend generators must also provide an explicit `endpoint`. + Primitive offers are local planning objects: - `endpoint_at(parameter)` returns the local output `Port` diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index 8b138a8..280f9e5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -271,13 +271,13 @@ class PrimitiveWireTool(Tool): **kwargs: Any, ) -> tuple[StraightOffer | BendOffer, ...]: if kind == 'straight': - route_kwargs = dict(kwargs) + tool_options = dict(kwargs) def endpoint_planner(length: float) -> Port: return Port((length, 0), rotation=pi, ptype=self.ptype) def commit_planner(length: float) -> WireStraightData: - _ = route_kwargs + _ = tool_options return WireStraightData(length) native_offer = StraightOffer( @@ -354,7 +354,6 @@ class PrimitiveWireTool(Tool): batch: Sequence[RenderStep], *, port_names: tuple[str, str] = ('A', 'B'), - **kwargs: Any, # noqa: ARG002 (no per-render options in this example tool) ) -> ILibrary: tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_wire') pat.add_port_pair(names=port_names, ptype=batch[0].start_port.ptype if batch else self.ptype) diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 9d6b46b..da1c0d9 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -8,6 +8,10 @@ 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. + Routing is split into four ownership phases: - snapshot: `Pather` resolves the active Tool for each requested port and passes copied ports to the planner as `RoutePortContext` values, @@ -84,6 +88,29 @@ from .logging import PatherLogger logger = logging.getLogger(__name__) RenderPolicy = Literal['auto', 'immediate', 'deferred', 'warn', 'error', 'ignore'] RENDER_POLICIES: tuple[RenderPolicy, ...] = ('auto', 'immediate', 'deferred', 'warn', 'error', 'ignore') +RESERVED_TOOL_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw')) + + +def _present_route_args(**kwargs: Any) -> dict[str, Any]: + """Return explicitly supplied route arguments for planner validation.""" + return {key: value for key, value in kwargs.items() if value is not None} + + +def _validated_tool_options(tool_options: Mapping[str, Any] | None) -> dict[str, Any]: + """Copy and validate custom planning options before any Tool is queried.""" + if tool_options is None: + return {} + try: + options = dict(tool_options) + except (TypeError, ValueError) as err: + raise BuildError('tool_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'tool_options keys must be strings; got {nonstring!r}') + collisions = sorted(RESERVED_TOOL_OPTION_KEYS & options.keys()) + if collisions: + raise BuildError(f'tool_options cannot override Tool arguments: {", ".join(collisions)}') + return options class Pather(PortList): @@ -548,7 +575,19 @@ class Pather(PortList): *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, - **bounds: Any, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, ) -> Self: """ Route one or more ports using straight segments or single 90-degree bends. @@ -567,12 +606,33 @@ class Pather(PortList): `spacing` and `set_rotation` are only valid when using a bundle bound. """ - with self._logger.log_operation(self, 'trace', portspec, ccw=ccw, length=length, spacing=spacing, strategy=strategy, **bounds): + bounds = _present_route_args( + 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, + ) + options = _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, + ): 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, **bounds) + result = self.planner.plan_trace_route( + contexts, ccw, length, spacing=spacing, strategy=strategy, + tool_options=options, **bounds, + ) except (BuildError, NotImplementedError) as err: if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: raise @@ -584,7 +644,7 @@ class Pather(PortList): 0, ccw, context.port.ptype, - out_ptype = bounds.get('out_ptype'), + out_ptype = out_ptype, ) return self if bounds.get('each') is not None: @@ -596,7 +656,7 @@ class Pather(PortList): 0, ccw, context.port.ptype, - out_ptype = bounds.get('out_ptype'), + out_ptype = out_ptype, ) return self raise @@ -608,9 +668,27 @@ class Pather(PortList): portspec: str | Sequence[str], ccw: SupportsBool | None, *, + length: float | None = None, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, - **bounds: Any, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, ) -> Self: """ Route until a single positional bound is reached, or delegate to `trace()` for length/bundle bounds. @@ -624,12 +702,39 @@ class Pather(PortList): `strategy` controls straight-first vs turn-first ordering only after cost and structural tie-breakers. """ - with self._logger.log_operation(self, 'trace_to', portspec, ccw=ccw, spacing=spacing, strategy=strategy, **bounds): + bounds = _present_route_args( + length=length, + 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, + ) + options = _validated_tool_options(tool_options) + with self._logger.log_operation( + self, 'trace_to', portspec, ccw=ccw, spacing=spacing, + strategy=strategy, tool_options=options, **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, **bounds) + result = self.planner.plan_trace_to_route( + contexts, ccw, spacing=spacing, strategy=strategy, + tool_options=options, **bounds, + ) except (BuildError, NotImplementedError) as err: if ( not self._dead @@ -655,23 +760,148 @@ class Pather(PortList): 0, ccw, context.port.ptype, - out_ptype = bounds.get('out_ptype'), + out_ptype = out_ptype, ) return self self._apply_route_result(result) return self - def straight(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - return self.trace_to(portspec, None, length=length, **bounds) + def straight( + self, + portspec: str | Sequence[str], + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.trace_to( + portspec, None, length=length, spacing=spacing, strategy=strategy, + 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, + ) - def bend(self, portspec: str | Sequence[str], ccw: SupportsBool, length: float | None = None, **bounds) -> Self: - return self.trace_to(portspec, ccw, length=length, **bounds) + def bend( + self, + portspec: str | Sequence[str], + ccw: SupportsBool, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.trace_to( + portspec, ccw, length=length, spacing=spacing, strategy=strategy, + 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, + ) - def ccw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - return self.bend(portspec, True, length, **bounds) + def ccw( + self, + portspec: str | Sequence[str], + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.bend( + portspec, True, length, spacing=spacing, strategy=strategy, 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, + ) - def cw(self, portspec: str | Sequence[str], length: float | None = None, **bounds) -> Self: - return self.bend(portspec, False, length, **bounds) + def cw( + self, + portspec: str | Sequence[str], + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.bend( + portspec, False, length, spacing=spacing, strategy=strategy, 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, + ) def jog( self, @@ -681,7 +911,13 @@ class Pather(PortList): *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, - **bounds: Any, + out_ptype: str | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + tool_options: Mapping[str, Any] | None = None, ) -> Self: """ Route an S-bend. @@ -700,12 +936,20 @@ class Pather(PortList): controls straight-first vs S-first ordering only after cost and structural tie-breakers. """ - with self._logger.log_operation(self, 'jog', portspec, offset=offset, length=length, spacing=spacing, strategy=strategy, **bounds): + bounds = _present_route_args(out_ptype=out_ptype, p=p, pos=pos, position=position, x=x, y=y) + options = _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, + ): 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, **bounds) + result = self.planner.plan_jog_route( + contexts, offset, length, spacing=spacing, strategy=strategy, + tool_options=options, **bounds, + ) except (BuildError, NotImplementedError) as err: if ( not self._dead @@ -723,7 +967,7 @@ class Pather(PortList): 0, None, context.port.ptype, - out_ptype = bounds.get('out_ptype'), + out_ptype = out_ptype, ) return self fallback_length = length if length is not None else 0 @@ -735,7 +979,7 @@ class Pather(PortList): None, context.port.ptype, out_rot = pi, - out_ptype = bounds.get('out_ptype'), + out_ptype = out_ptype, ) return self self._apply_route_result(result) @@ -749,7 +993,8 @@ class Pather(PortList): *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, - **bounds: Any, + out_ptype: str | None = None, + tool_options: Mapping[str, Any] | None = None, ) -> Self: """ Route a U-turn. @@ -766,12 +1011,20 @@ class Pather(PortList): controls straight-first vs U-first ordering only after cost and structural tie-breakers. """ - with self._logger.log_operation(self, 'uturn', portspec, offset=offset, length=length, spacing=spacing, strategy=strategy, **bounds): + bounds = _present_route_args(out_ptype=out_ptype) + options = _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, + ): 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, **bounds) + result = self.planner.plan_uturn_route( + contexts, offset, length, spacing=spacing, strategy=strategy, + tool_options=options, **bounds, + ) except (BuildError, NotImplementedError) as err: if ( not self._dead @@ -788,7 +1041,7 @@ class Pather(PortList): None, context.port.ptype, out_rot = 0.0, - out_ptype = bounds.get('out_ptype'), + out_ptype = out_ptype, ) return self self._apply_route_result(result) @@ -803,7 +1056,7 @@ class Pather(PortList): plug_destination: bool = True, thru: str | None = None, strategy: RouteTieBreakStrategy | str | None = None, - **kwargs: Any, + tool_options: Mapping[str, Any] | None = None, ) -> Self: """ Route one port into another using a bounded primitive-offer selection. @@ -813,9 +1066,8 @@ class Pather(PortList): dogleg topologies. The lowest-cost legal bounded candidate is selected; bend count, step count, and search order are tie-breakers only. `strategy` controls straight-first vs turn-first search order within those ties. - Route-shape kwargs such as `length`, `offset`, `ccw`, positional bounds, - bundle bounds, and `strategy` are reserved for this internal solve; other tool - kwargs are forwarded to primitive offer generation. + Custom planning options may be supplied through `tool_options`; they are + forwarded only to primitive offer generation. If `plug_destination` is `True`, the destination port is consumed by the final step. If `thru` is provided, that port is renamed to the source name after the route is complete. @@ -824,6 +1076,7 @@ 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) with self._logger.log_operation( self, 'trace_into', @@ -832,7 +1085,7 @@ class Pather(PortList): plug_destination=plug_destination, thru=thru, strategy=strategy, - **kwargs, + tool_options=options, ): result = self.planner.plan_trace_into( self._route_context(portspec_src), @@ -842,7 +1095,7 @@ class Pather(PortList): plug_destination = plug_destination, thru = thru, strategy = strategy, - **kwargs, + tool_options = options, ) self._apply_route_result(result) return self @@ -1045,46 +1298,259 @@ class PortPather: with self.pather.toolctx(tool, keys=self.ports): yield self - def trace(self, ccw: SupportsBool | None, length: float | None = None, **kw: Any) -> Self: - if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: - kw['spacing'] = self.default_spacing - self.pather.trace(self.ports, ccw, length, **kw) + def trace( + self, + ccw: SupportsBool | None, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | 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 and ccw is not None: + spacing = self.default_spacing + self.pather.trace( + self.ports, ccw, length, spacing=spacing, strategy=strategy, 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, + ) return self - def trace_to(self, ccw: SupportsBool | None, **kw: Any) -> Self: - if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and ccw is not None: - kw['spacing'] = self.default_spacing - self.pather.trace_to(self.ports, ccw, **kw) + def trace_to( + self, + ccw: SupportsBool | None, + *, + length: float | None = None, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | 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 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, + 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, + ) return self - def straight(self, length: float | None = None, **kw: Any) -> Self: - return self.trace_to(None, length=length, **kw) + def straight( + self, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.trace_to( + None, length=length, spacing=spacing, strategy=strategy, 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, + ) - def bend(self, ccw: SupportsBool, length: float | None = None, **kw: Any) -> Self: - return self.trace_to(ccw, length=length, **kw) + def bend( + self, + ccw: SupportsBool, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.trace_to( + ccw, length=length, spacing=spacing, strategy=strategy, 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, + ) - def ccw(self, length: float | None = None, **kw: Any) -> Self: - return self.bend(True, length, **kw) + def ccw( + self, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.bend( + True, length, spacing=spacing, strategy=strategy, 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, + ) - def cw(self, length: float | None = None, **kw: Any) -> Self: - return self.bend(False, length, **kw) + def cw( + self, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + each: float | None = None, + set_rotation: float | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | None = None, + emin: float | None = None, + emax: float | None = None, + pmin: float | None = None, + pmax: float | None = None, + xmin: float | None = None, + xmax: float | None = None, + ymin: float | None = None, + ymax: float | None = None, + min_past_furthest: float | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + return self.bend( + False, length, spacing=spacing, strategy=strategy, 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, + ) - def jog(self, offset: float, length: float | None = None, **kw: Any) -> Self: - if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1 and not numpy.isclose(offset, 0): - kw['spacing'] = self.default_spacing - self.pather.jog(self.ports, offset, length, **kw) + def jog( + self, + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + out_ptype: str | None = None, + p: float | None = None, + pos: float | None = None, + position: float | None = None, + x: float | None = None, + y: float | 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 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, + p=p, pos=pos, position=position, x=x, y=y, tool_options=tool_options, + ) return self - def uturn(self, offset: float, length: float | None = None, **kw: Any) -> Self: - if 'spacing' not in kw and self.default_spacing is not None and len(self.ports) > 1: - kw['spacing'] = self.default_spacing - self.pather.uturn(self.ports, offset, length, **kw) + def uturn( + self, + offset: float, + length: float | None = None, + *, + spacing: float | ArrayLike | None = None, + strategy: RouteTieBreakStrategy | str | 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, + out_ptype=out_ptype, tool_options=tool_options, + ) return self - def trace_into(self, target_port: str, **kwargs) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit trace_into() with {len(self.ports)} (>1) ports.') - self.pather.trace_into(self.ports[0], target_port, **kwargs) + def trace_into( + self, + target_port: str, + *, + out_ptype: str | None = None, + plug_destination: bool = True, + thru: str | None = None, + strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, + ) -> Self: + if len(self.ports) != 1: + raise BuildError(f'Unable to use implicit trace_into() with {len(self.ports)} ports; expected exactly one.') + self.pather.trace_into( + self.ports[0], target_port, out_ptype=out_ptype, plug_destination=plug_destination, + thru=thru, strategy=strategy, tool_options=tool_options, + ) return self def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self: diff --git a/masque/builder/planner/bounds.py b/masque/builder/planner/bounds.py index 43224a0..5b4404b 100644 --- a/masque/builder/planner/bounds.py +++ b/masque/builder/planner/bounds.py @@ -20,7 +20,7 @@ from pprint import pformat import numpy from numpy import pi -from numpy.typing import ArrayLike +from numpy.typing import ArrayLike, NDArray from ...error import BuildError, PortError from ...ports import Port @@ -34,6 +34,22 @@ BUNDLE_BOUND_KEYS: tuple[str, ...] = ( ) +def finite_scalar(value: Any, name: str, *, nonnegative: bool = False) -> float: + """Return a finite scalar, preserving duck-typed numeric inputs.""" + try: + array = numpy.asarray(value, dtype=float) + except (TypeError, ValueError) as err: + raise BuildError(f'{name} must be a finite numeric scalar') from err + if array.size != 1: + raise BuildError(f'{name} must be a scalar; got {array.size} values') + result = float(array.reshape(-1)[0]) + if not numpy.isfinite(result): + raise BuildError(f'{name} must be finite') + if nonnegative and result < 0: + raise BuildError(f'{name} must be nonnegative') + return result + + def resolved_position_bound( port: Port, bounds: Mapping[str, Any], @@ -50,7 +66,8 @@ def resolved_position_bound( if not allow_length and bounds.get('length') is not None: raise BuildError('length cannot be combined with a positional bound') - key, value = present[0] + key, raw_value = present[0] + value = finite_scalar(raw_value, f'{key} positional bound') if port.rotation is None: raise BuildError('Ports must have rotation') is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9)) @@ -206,6 +223,8 @@ def su_bundle_specs( """ if spacing is None: raise BuildError(f'Must provide spacing for multi-port {route_name}()') + finite_scalar(offset, 'offset') + finite_scalar(length, 'length', nonnegative=True) ports = {context.portspec: context.port for context in contexts} has_rotation = numpy.array([port.rotation is not None for port in ports.values()], dtype=bool) @@ -229,7 +248,9 @@ def su_bundle_specs( y_order = ((-1 if first_ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable') spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) - steps = numpy.zeros(len(ports), dtype=float) + if numpy.any(spacing_arr < 0): + raise BuildError('spacing must be nonnegative') + steps: NDArray[numpy.float64] = numpy.zeros(len(ports), dtype=float) if spacing_arr.size == 1: steps[1:] = spacing_arr[0] elif spacing_arr.size == len(ports) - 1: diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 6e6a3c0..8f483a7 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -251,7 +251,7 @@ class SolverRequest: Public Pather calls are converted into this smaller shape before grammar enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints; - `route_kwargs` are forwarded to Tool primitive-offer generation. + `tool_options` are forwarded to Tool primitive-offer generation. """ family: PrimitiveKind """High-level route family being solved.""" @@ -259,8 +259,8 @@ class SolverRequest: """Tool queried for primitive offers.""" in_ptype: str | None """Input ptype at the start of the route.""" - route_kwargs: Mapping[str, Any] - """Tool kwargs forwarded to primitive-offer generation.""" + tool_options: Mapping[str, Any] + """Custom Tool kwargs forwarded to primitive-offer generation.""" length: float | None = None """Requested local x displacement, when constrained.""" jog: float | None = None @@ -439,9 +439,8 @@ class Solver: out_ptype: str | None = None, extra: Mapping[str, Any] | None = None, ) -> tuple[PrimitiveOffer, ...]: - """Query the active Tool with route kwargs and per-query overrides.""" - kwargs = dict(self.request.route_kwargs) - kwargs.pop('out_ptype', None) + """Query the active Tool with custom planning options and internal overrides.""" + kwargs = dict(self.request.tool_options) if extra: kwargs.update(extra) try: @@ -1018,6 +1017,7 @@ class RoutingPlanner: constrain_jog: bool = False, max_bends: int | None = None, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **kwargs: Any, ) -> SolverRequest: """Build normalized solver input for one route leg.""" @@ -1025,7 +1025,7 @@ class RoutingPlanner: family=family, tool=context.tool, in_ptype=context.port.ptype, - route_kwargs=kwargs, + tool_options={} if tool_options is None else dict(tool_options), length=length, jog=jog, ccw=ccw, @@ -1070,6 +1070,7 @@ class RoutingPlanner: constrain_jog: bool = False, max_bends: int | None = None, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **kwargs: Any, ) -> RouteLeg: """Solve one route leg and attach it to its source Pather context.""" @@ -1107,6 +1108,7 @@ class RoutingPlanner: constrain_jog=constrain_jog, max_bends=max_bends, strategy=strategy, + tool_options=tool_options, **kwargs, ) try: @@ -1212,6 +1214,7 @@ class RoutingPlanner: *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan straight or single-bend traces, including `each` and bundle-bound modes.""" @@ -1225,6 +1228,8 @@ class RoutingPlanner: request_details['spacing'] = spacing if strategy is not None: request_details['strategy'] = strategy + if tool_options: + request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'trace' diagnostic_request = request_details return self._plan_trace_route( @@ -1235,6 +1240,7 @@ class RoutingPlanner: diagnostic_request, spacing=spacing, strategy=strategy, + tool_options=tool_options, **route_bounds, ) @@ -1249,6 +1255,7 @@ class RoutingPlanner: *, spacing: float | ArrayLike | None, strategy: RouteTieBreakStrategy | str | None, + tool_options: Mapping[str, Any] | None, **route_bounds: Any, ) -> PreparedRouteResult: portspec = tuple(context.portspec for context in contexts) @@ -1263,6 +1270,7 @@ class RoutingPlanner: length=length, ccw=ccw, strategy=strategy, + tool_options=tool_options, **route_bounds, ) return self.prepared_result_from_legs((leg,)) @@ -1279,6 +1287,7 @@ class RoutingPlanner: length=each, ccw=ccw, strategy=strategy, + tool_options=tool_options, **route_bounds, ), ) @@ -1295,6 +1304,7 @@ class RoutingPlanner: length=None, ccw=ccw, strategy=strategy, + tool_options=tool_options, **route_bounds, ) return self.prepared_result_from_legs((leg,)) @@ -1321,6 +1331,7 @@ class RoutingPlanner: length=route_length, ccw=ccw, strategy=strategy, + tool_options=tool_options, **route_bounds, ) actions.append(self.prepared_route_action_from_leg(leg)) @@ -1333,6 +1344,7 @@ class RoutingPlanner: *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan `trace_to()` by resolving positional targets or delegating to `trace()` modes.""" @@ -1344,6 +1356,8 @@ class RoutingPlanner: request_details['spacing'] = spacing if strategy is not None: request_details['strategy'] = strategy + if tool_options: + request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'trace_to' diagnostic_request = request_details return self._plan_trace_to_route( @@ -1353,6 +1367,7 @@ class RoutingPlanner: diagnostic_request, spacing=spacing, strategy=strategy, + tool_options=tool_options, **route_bounds, ) @@ -1366,6 +1381,7 @@ class RoutingPlanner: *, spacing: float | ArrayLike | None, strategy: RouteTieBreakStrategy | str | None, + tool_options: Mapping[str, Any] | None, **route_bounds: Any, ) -> PreparedRouteResult: if len(contexts) == 1: @@ -1383,6 +1399,7 @@ class RoutingPlanner: diagnostic_request, spacing=spacing, strategy=strategy, + tool_options=tool_options, **route_bounds, ) @@ -1402,6 +1419,7 @@ class RoutingPlanner: length=length, ccw=ccw, strategy=strategy, + tool_options=tool_options, **other_bounds, ) return self.prepared_result_from_legs((leg,)) @@ -1414,9 +1432,11 @@ class RoutingPlanner: *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan S-bend routes for single ports or spaced bundles.""" + offset = planner_bounds.finite_scalar(offset, 'offset') request_details = {'offset': offset, **{ key: value for key, value in bounds.items() if value is not None }} @@ -1426,6 +1446,8 @@ class RoutingPlanner: request_details['spacing'] = spacing if strategy is not None: request_details['strategy'] = strategy + if tool_options: + request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'jog' diagnostic_request = request_details if numpy.isclose(offset, 0): @@ -1437,6 +1459,7 @@ class RoutingPlanner: length=length, spacing=spacing, strategy=strategy, + tool_options=tool_options, **bounds, ) route_bounds = dict(bounds) @@ -1460,6 +1483,7 @@ class RoutingPlanner: operation, diagnostic_request, strategy=strategy, + tool_options=tool_options, **other_bounds, ) )) @@ -1471,6 +1495,7 @@ class RoutingPlanner: length=length, jog=offset, strategy=strategy, + tool_options=tool_options, **other_bounds, ) return self.prepared_result_from_legs((leg,)) @@ -1483,9 +1508,11 @@ class RoutingPlanner: *, spacing: float | ArrayLike | None = None, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **bounds: Any, ) -> PreparedRouteResult: """Plan U-turn routes for single ports or spaced bundles.""" + offset = planner_bounds.finite_scalar(offset, 'offset') route_bounds = dict(bounds) request_details = {'offset': offset, **{ key: value for key, value in route_bounds.items() if value is not None @@ -1496,6 +1523,8 @@ class RoutingPlanner: request_details['spacing'] = spacing if strategy is not None: request_details['strategy'] = strategy + if tool_options: + request_details['tool_options'] = dict(tool_options) operation: RouteOperation = 'uturn' diagnostic_request = request_details portspec = tuple(context.portspec for context in contexts) @@ -1512,6 +1541,7 @@ class RoutingPlanner: operation, diagnostic_request, strategy=strategy, + tool_options=tool_options, **route_bounds, ) )) @@ -1523,6 +1553,7 @@ class RoutingPlanner: length=length, jog=offset, strategy=strategy, + tool_options=tool_options, **route_bounds, ) return self.prepared_result_from_legs((leg,)) @@ -1539,6 +1570,7 @@ class RoutingPlanner: /, *, strategy: RouteTieBreakStrategy | str | None = None, + tool_options: Mapping[str, Any] | None = None, **kwargs: Any, ) -> tuple[RouteLeg, ...]: """ @@ -1557,6 +1589,7 @@ class RoutingPlanner: length=length, jog=offset, strategy=strategy, + tool_options=tool_options, **kwargs, ),) route_name = 'jog' if kind == 's' else 'uturn' @@ -1573,6 +1606,7 @@ class RoutingPlanner: length=length, jog=offset, strategy=strategy, + tool_options=tool_options, **kwargs, ) base_length = anchor.candidate.public_length @@ -1589,6 +1623,7 @@ class RoutingPlanner: length=spec_length, jog=spec_offset, strategy=strategy, + tool_options=tool_options, **kwargs, ) else: @@ -1600,6 +1635,7 @@ class RoutingPlanner: length=spec_length, jog=spec_offset, strategy=strategy, + tool_options=tool_options, **kwargs, ) return tuple(routes_by_name[spec_portspec] for spec_portspec, _length, _offset in specs) @@ -1614,17 +1650,9 @@ class RoutingPlanner: plug_destination: bool, thru: str | None, strategy: RouteTieBreakStrategy | str | None = None, - **kwargs: Any, + tool_options: Mapping[str, Any] | None = None, ) -> PreparedRouteResult: """Plan a bounded route from one source port into a destination port.""" - reserved = { - 'portspec', 'ccw', 'length', 'offset', 'plug_into', 'spacing', 'each', 'set_rotation', - *planner_bounds.POSITION_KEYS, - *planner_bounds.BUNDLE_BOUND_KEYS, - } - collisions = sorted(set(kwargs) & reserved) - if collisions: - raise BuildError(f'trace_into() kwargs cannot override route arguments: {", ".join(collisions)}') if out_ptype is None: out_ptype = port_dst.ptype if context_src.port.rotation is None or port_dst.rotation is None: @@ -1642,7 +1670,8 @@ class RoutingPlanner: constrain_jog=family == 'bend', max_bends=self.TRACE_INTO_MAX_BENDS, strategy=strategy, - **(dict(kwargs) | {'out_ptype': out_ptype}), + tool_options=tool_options, + out_ptype=out_ptype, ) solver = self.solver_for_request(request) candidate = None diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 9db7645..9967e62 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -49,8 +49,9 @@ from typing import Literal, Any, Self from collections import ChainMap from collections.abc import Sequence, Callable, Mapping from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt +from types import MappingProxyType import numpy from numpy.typing import NDArray @@ -693,6 +694,10 @@ class Tool(ABC): - `'s'`: a non-turning `SOffer` - `'u'`: an `UOffer` + Other `kwargs` come only from the Pather call's explicit + `tool_options` mapping. Tools are responsible for validating the keys + they support. + `Pather` applies any requested `out_ptype` to the final route endpoint, not to every primitive in the route. Intermediate ptypes are solver-selected, and heterogeneous straight/S offers may be used as @@ -706,7 +711,6 @@ class Tool(ABC): batch: Sequence[RenderStep], *, port_names: tuple[str, str] = ('A', 'B'), - **kwargs, ) -> ILibrary: """ Render a compatible batch of selected route steps into geometry. @@ -722,7 +726,6 @@ class Tool(ABC): primitive render data. port_names: The topcell's input and output ports should be named `port_names[0]` and `port_names[1]` respectively. - kwargs: Custom tool-specific parameters. """ raise NotImplementedError @@ -778,17 +781,20 @@ class AutoTool(Tool): when only the declared direction should be available. Straight and S-bend generator functions may return either a `Pattern` or a - single-top `Library`. Extra keyword arguments passed to `render()` are - forwarded to those generators. + single-top `Library`. Route `tool_options` are snapshotted into committed + generated data and passed to each selected generator as keyword arguments. + These options are render-only: they may change geometry, layers, or + annotations, but must not change generated port topology or endpoints. """ @dataclass(frozen=True, slots=True) class GeneratedData: - """ Deferred render data for one generated primitive offer. """ + """Deferred generator call, including its route-specific option snapshot.""" fn: GeneratedPrimitiveFn port_name: str parameter: float mirrored: bool = False + tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) @dataclass(frozen=True, slots=True) class ReusableData: @@ -972,7 +978,7 @@ class AutoTool(Tool): If `ptype` or `in_port_name` is omitted, one in-domain example is generated and the missing metadata is inferred from an equivalent - two-port straight. + two-port straight. Metadata inference does not receive route options. """ ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name) priority_bias = len(self._straight_offers) * BUILTIN_PRIORITY_STEP @@ -1062,6 +1068,7 @@ class AutoTool(Tool): If `ptype` or port names are omitted, one in-domain example is generated and the missing metadata is inferred from an equivalent two-port S-bend. + Metadata inference and endpoint planning do not receive route options. """ ptype, in_port_name, out_port_name = self._infer_sbend_metadata( fn, @@ -1225,7 +1232,7 @@ class AutoTool(Tool): dxy, _ = sbpat[in_port_name].measure_travel(sbpat[out_port_name]) return dxy - def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], None]) -> NDArray[numpy.float64]: + def _rendered_bbox(self, render: Callable[[ILibrary, tuple[str, str]], Any]) -> NDArray[numpy.float64]: port_names = ('A', 'B') tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'primitive_bbox') pat.add_port_pair(names=port_names) @@ -1247,7 +1254,7 @@ class AutoTool(Tool): return numpy.asarray(bounds, dtype=float) def _bbox_for_data(self, data: Any) -> NDArray[numpy.float64]: - return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names, {})) + return self._rendered_bbox(lambda tree, names: self._render_data(data, tree, names)) def _add_transition_direction( self, @@ -1330,21 +1337,45 @@ class AutoTool(Tool): **kwargs, ) -> tuple[PrimitiveOffer, ...]: _ = out_ptype + ccw = kwargs.pop('ccw', None) + tool_options = MappingProxyType(dict(kwargs)) + + def configured(offer: PrimitiveOffer) -> PrimitiveOffer: + if not tool_options: + return offer + + def commit(parameter: float) -> Any: + data = offer.commit(parameter) + if not isinstance(data, self.GeneratedData): + raise BuildError('AutoTool generated offer returned unexpected commit data') + return replace(data, tool_options=tool_options) + + def bbox(parameter: float) -> NDArray[numpy.float64]: + return self._bbox_for_data(commit(parameter)) + + return replace( + offer, + commit_planner=commit, + bbox_planner=bbox if offer.bbox_planner is not None else None, + ) + if kind == 'straight': in_key = self._wildcard_ptype_key(in_ptype) return ( *self._transition_adapter_offers_by_key.get(('straight', in_key), ()), - *self._straight_offers, + *(configured(offer) for offer in self._straight_offers), ) if kind == 'bend': - return tuple(self._bend_offers[int(bool(kwargs['ccw']))]) + if ccw is None: + raise BuildError('AutoTool bend offer discovery requires ccw') + return tuple(self._bend_offers[int(bool(ccw))]) if kind == 's': in_key = self._wildcard_ptype_key(in_ptype) return ( *self._transition_adapter_offers_by_key.get(('s', in_key), ()), - *self._s_offers, + *(configured(offer) for offer in self._s_offers), ) if kind == 'u': @@ -1356,13 +1387,12 @@ class AutoTool(Tool): data: GeneratedData, tree: ILibrary, port_names: tuple[str, str], - gen_kwargs: dict[str, Any], ) -> ILibrary: if numpy.isclose(data.parameter, 0): return tree pat = tree.top_pattern() - generated = data.fn(data.parameter, **gen_kwargs) + generated = data.fn(data.parameter, **data.tool_options) pmap = {port_names[1]: data.port_name} if isinstance(generated, Pattern): pat.plug(generated, pmap, append=True, mirrored=data.mirrored) @@ -1387,10 +1417,9 @@ class AutoTool(Tool): data: Any, tree: ILibrary, port_names: tuple[str, str], - gen_kwargs: dict[str, Any], ) -> ILibrary: if isinstance(data, self.GeneratedData): - return self._render_generated(data=data, tree=tree, port_names=port_names, gen_kwargs=gen_kwargs) + return self._render_generated(data=data, tree=tree, port_names=port_names) if isinstance(data, self.ReusableData): return self._render_reusable(data=data, tree=tree, port_names=port_names) raise BuildError(f'Unexpected AutoTool render data {type(data).__name__}') @@ -1400,7 +1429,6 @@ class AutoTool(Tool): batch: Sequence[RenderStep], *, port_names: tuple[str, str] = ('A', 'B'), - **kwargs, ) -> ILibrary: tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'traceL') @@ -1408,7 +1436,7 @@ class AutoTool(Tool): for step in batch: assert step.tool == self - self._render_data(step.data, tree, port_names, kwargs) + self._render_data(step.data, tree, port_names) return tree @@ -1470,6 +1498,10 @@ class PathTool(Tool): out_ptype: str | None = None, **kwargs, ) -> tuple[PrimitiveOffer, ...]: + allowed = {'ccw'} if kind == 'bend' else set() + unsupported = sorted(set(kwargs) - allowed) + if unsupported: + raise BuildError(f'PathTool does not support tool options: {", ".join(unsupported)}') if kind == 'u': return () @@ -1559,7 +1591,6 @@ class PathTool(Tool): batch: Sequence[RenderStep], *, port_names: tuple[str, str] = ('A', 'B'), - **kwargs, # noqa: ARG002 (unused) ) -> ILibrary: # Transform the batch so the first port is local (at 0,0) but retains its global rotation. diff --git a/masque/builder/utils.py b/masque/builder/utils.py index ceea550..e8f06a7 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -131,6 +131,8 @@ def ell( else: if set_rotation is None: raise BuildError('set_rotation must be specified if no ports have rotations!') + if not numpy.isfinite(set_rotation): + raise BuildError('set_rotation must be finite') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) is_horizontal = numpy.isclose(rotations[0] % pi, 0) @@ -156,7 +158,11 @@ def ell( ch_offsets = numpy.zeros_like(y_order) else: spacing_arr = numpy.asarray(spacing, dtype=float).reshape(-1) - steps = numpy.zeros_like(y_order) + if not numpy.all(numpy.isfinite(spacing_arr)): + raise BuildError('spacing must contain only finite values') + if numpy.any(spacing_arr < 0): + raise BuildError('spacing must be nonnegative') + steps: NDArray[numpy.float64] = numpy.zeros(len(y_order), dtype=float) if spacing_arr.size == 1: steps[1:] = spacing_arr[0] elif spacing_arr.size == len(ports) - 1: diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py index f29b741..50df07f 100644 --- a/masque/test/test_autotool_planning.py +++ b/masque/test/test_autotool_planning.py @@ -800,7 +800,7 @@ def test_autotool_s_offer_bbox_matches_rendered_sbend() -> None: .add_sbend(make_wide_sbend, "core", "A", "B", jog_range=(0, 1e8)) .add_transition(lib.abstract("xin"), "EXT", "CORE") ) - offer, _out_port, _data = selected_offer(tool, "s", 4, length=15, in_ptype="core") + offer, _out_port, _data = selected_offer(tool, "s", 4, in_ptype="core") assert_offer_bbox_matches_rendered_offer(tool, offer, 4, lib) @@ -1006,7 +1006,7 @@ def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, L assert bend_step.data.abstract.name == "b1" -def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None: +def test_autotool_generated_primitives_snapshot_route_options() -> None: markers: list[str | None] = [] def make_marked_straight(length: float, marker: str | None = None) -> Pattern: @@ -1017,10 +1017,90 @@ def test_autotool_generated_primitives_do_not_capture_route_kwargs() -> None: p = Pather(Library(), tools=tool, render='deferred') p.ports["A"] = Port((0, 0), 0, ptype="wire") - p.straight("A", 5, marker="route") + first_options = {'marker': 'first'} + p.straight("A", 5, tool_options=first_options) + first_options['marker'] = 'mutated' + p.straight("A", 6, tool_options={'marker': 'second'}) + + first_data, second_data = (step.data for step in p._paths['A']) + assert isinstance(first_data, AutoTool.GeneratedData) + assert isinstance(second_data, AutoTool.GeneratedData) + assert dict(first_data.tool_options) == {'marker': 'first'} + assert dict(second_data.tool_options) == {'marker': 'second'} + p.render() - assert markers == [None] + assert markers == ['first', 'second'] + + +def test_autotool_route_options_do_not_attach_to_reusable_bends( + multi_bend_tool: tuple[AutoTool, Library], + ) -> None: + tool, _lib = multi_bend_tool + offers = tool.primitive_offers('bend', in_ptype='wire', ccw=True, marker='ignored') + + assert offers + assert all(isinstance(offer.commit(offer.parameter_domain[0]), AutoTool.ReusableData) for offer in offers) + + +def test_autotool_sbend_generator_receives_route_options() -> None: + markers: list[str] = [] + + def make_marked_sbend(jog: float, *, marker: str) -> Pattern: + markers.append(marker) + pat = Pattern() + pat.ports['A'] = Port((0, 0), 0, ptype='wire') + pat.ports['B'] = Port((3, jog), pi, ptype='wire') + return pat + + tool = AutoTool().add_sbend( + make_marked_sbend, + 'wire', + 'A', + 'B', + endpoint=lambda jog: Port((3, jog), pi, ptype='wire'), + ) + p = Pather(Library(), tools=tool, render='deferred') + p.ports['A'] = Port((0, 0), 0, ptype='wire') + + p.jog('A', 4, length=3, tool_options={'marker': 'sbend'}) + p.render() + + assert markers == ['sbend'] + + +def test_autotool_generated_bbox_uses_route_options() -> None: + tool = AutoTool().add_straight(make_straight, 'wire', 'A') + offer = tool.primitive_offers('straight', width=6)[0] + + assert_allclose(offer.bbox_at(10), [[0, -3], [10, 3]]) + + +def test_autotool_unsupported_generator_option_fails_during_render() -> None: + tool = AutoTool().add_straight(make_straight, 'wire', 'A') + p = Pather(Library(), tools=tool, render='deferred') + p.ports['A'] = Port((0, 0), 0, ptype='wire') + + p.straight('A', 5, tool_options={'unknown_generator_option': True}) + + with pytest.raises(TypeError, match='unknown_generator_option'): + p.render() + + +def test_autotool_generator_options_must_preserve_planned_endpoint() -> None: + def shifted_straight(length: float, endpoint_shift: float = 0) -> Pattern: + pat = make_straight(length) + pat.ports['B'].x += endpoint_shift + return pat + + tool = AutoTool().add_straight(shifted_straight, 'wire', 'A') + p = Pather(Library(), tools=tool, render='deferred') + p.ports['A'] = Port((0, 0), 0, ptype='wire') + + p.straight('A', 5, tool_options={'endpoint_shift': 1}) + + with pytest.raises(BuildError, match='does not match planned endpoint'): + p.render() @pytest.mark.parametrize("ccw", [False, True]) @@ -1204,7 +1284,7 @@ def test_pather_autotool_pure_sbend_with_transition_dx() -> None: assert_allclose(trans_port.offset, [5, 0]) assert isinstance(trans_data, AutoTool.ReusableData) - s_offer, s_port, s_data = selected_offer(tool, "s", 4, length=15, in_ptype="core") + s_offer, s_port, s_data = selected_offer(tool, "s", 4, in_ptype="core") assert_allclose(s_port.offset, [10, 4]) assert isinstance(s_data, AutoTool.GeneratedData) assert s_offer.out_ptype == "core" diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index e1c0744..30cf34f 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -1,11 +1,12 @@ from collections.abc import Sequence from typing import Any, Literal, Never +import inspect import pytest import numpy from numpy import pi -from masque import MinimumStatus, Pather, Library, Port, RouteError, RouteFailurePolicy +from masque import MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy from masque.builder.planner import RoutePortContext, RoutingPlanner from masque.builder.planner.planner import NoLegalRouteError from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer @@ -199,7 +200,7 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None: ) with pytest.raises(RouteError) as exc_info: - p.ccw('A', 1, out_ptype='wide', diagnostic_context='tool-value') + p.ccw('A', 1, out_ptype='wide', tool_options={'diagnostic_context': 'tool-value'}) details = exc_info.value.details assert isinstance(exc_info.value, BuildError) @@ -211,7 +212,7 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None: 'ccw': True, 'length': 1, 'out_ptype': 'wide', - 'diagnostic_context': 'tool-value', + 'tool_options': {'diagnostic_context': 'tool-value'}, } assert details.resolved_length == 1 assert details.resolved_jog is None @@ -705,7 +706,7 @@ def test_pather_uturn_rejects_routing_bounds(kwargs: dict[str, int]) -> None: p = Pather(Library(), tools=PathTool(layer='M1', width=1, ptype='wire')) p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') - with pytest.raises(BuildError, match='Unsupported routing bounds for uturn'): + with pytest.raises(TypeError, match='unexpected keyword argument'): p.uturn('A', 4, **kwargs) def test_pather_uturn_omitted_length_uses_minimum_length_route() -> None: @@ -935,3 +936,74 @@ def test_pather_uturn_failed_two_bend_route_is_atomic() -> None: assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) assert p.pattern.ports['A'].rotation == 0 assert len(p._paths['A']) == 0 + + +@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_have_no_catch_all_kwargs(route_type: type, name: str) -> None: + parameters = inspect.signature(getattr(route_type, name)).parameters.values() + assert all(parameter.kind is not inspect.Parameter.VAR_KEYWORD for parameter in parameters) + + +def test_routing_typo_fails_before_tool_lookup() -> None: + tool = RequestCountingTool() + p = Pather(Library(), tools=tool, render='deferred') + + with pytest.raises(TypeError, match='unexpected keyword argument'): + p.straight('A', lenght=10) # type: ignore[call-arg] + + assert tool.offer_calls == 0 + + +@pytest.mark.parametrize('tool_options', [{1: 'bad'}, {'ccw': False}, {'out_ptype': 'bad'}]) +def test_tool_options_reject_invalid_or_reserved_keys(tool_options: dict[Any, Any]) -> None: + tool = RequestCountingTool() + p = Pather(Library(), tools=tool, render='deferred') + + with pytest.raises(BuildError, match='tool_options'): + p.trace('A', None, tool_options=tool_options) + + assert tool.offer_calls == 0 + + +@pytest.mark.parametrize( + 'operation', + [ + lambda p: p.jog('A', numpy.nan, length=1), + lambda p: p.uturn('A', numpy.inf, length=1), + lambda p: p.straight('A', x=numpy.nan), + ], + ids=['jog-offset', 'uturn-offset', 'position-bound'], + ) +def test_nonfinite_route_geometry_fails_before_offer_query(operation: 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='finite'): + operation(p) + + assert tool.offer_calls == 0 + + +@pytest.mark.parametrize('spacing', [-1, numpy.nan, numpy.inf]) +def test_invalid_bundle_spacing_fails_before_offer_query(spacing: float) -> None: + tool = RequestCountingTool() + p = Pather( + Library(), + ports={ + 'A': Port((0, 0), rotation=0, ptype='wire'), + 'B': Port((0, 4), rotation=0, ptype='wire'), + }, + tools=tool, + render='deferred', + ) + + with pytest.raises(BuildError, match='spacing'): + p.trace(['A', 'B'], True, xmin=-10, spacing=spacing) + + assert tool.offer_calls == 0 diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py index d1945c1..7dbce90 100644 --- a/masque/test/test_pather_primitive_offers.py +++ b/masque/test/test_pather_primitive_offers.py @@ -313,7 +313,7 @@ def test_pathtool_bend_offer_bbox_matches_path_bounds() -> None: def test_pathtool_s_offer_bbox_uses_intrinsic_minimum_length() -> None: tool = PathTool(layer=(1, 0), width=2, ptype='wire') - offer = tool.primitive_offers('s', in_ptype='wire', length=6)[0] + offer = tool.primitive_offers('s', in_ptype='wire')[0] bounds = offer.bbox_at(3) expected = Path(vertices=[(0, 0), (1, 0), (1, 3), (2, 3)], width=2).get_bounds_single() @@ -333,7 +333,7 @@ def test_pathtool_u_offers_remain_unsupported() -> None: [ ('straight', {}), ('bend', {'ccw': True}), - ('s', {'length': 6}), + ('s', {}), ], ) def test_pathtool_out_ptype_unk_is_wildcard( @@ -348,6 +348,12 @@ def test_pathtool_out_ptype_unk_is_wildcard( assert offers[0].out_ptype == 'wire' +def test_pathtool_rejects_unsupported_planning_options() -> None: + tool = PathTool(layer='M1', width=1) + with pytest.raises(BuildError, match='does not support tool options: marker'): + tool.primitive_offers('straight', marker='sentinel') + + def test_pather_treats_notimplemented_offer_query_as_no_offers() -> None: class NoOfferTool(PlanningOnlyTool): def primitive_offers( @@ -437,13 +443,14 @@ class StrategyTieTool(PlanningOnlyTool): ) -> tuple[PrimitiveOffer, ...]: self.seen_kwargs.append(dict(kwargs)) endpoint_ptype = out_ptype or in_ptype + marker = kwargs.get('marker') if kind == 'straight': return (StraightOffer( in_ptype=in_ptype, out_ptype=endpoint_ptype, **offer_callbacks(lambda length: ( Port((length, 0), rotation=pi, ptype=endpoint_ptype), - {'kind': 'straight', 'length': length}, + {'kind': 'straight', 'length': length, 'marker': marker}, )), ),) if kind == 's': @@ -452,7 +459,7 @@ class StrategyTieTool(PlanningOnlyTool): out_ptype=endpoint_ptype, **offer_callbacks(lambda jog: ( Port((3, jog), rotation=pi, ptype=endpoint_ptype), - {'kind': 's', 'jog': jog}, + {'kind': 's', 'jog': jog, 'marker': marker}, )), ),) return () @@ -504,11 +511,12 @@ def test_pather_route_strategy_per_route_can_request_turn_first() -> None: def test_pather_route_strategy_is_not_forwarded_to_tool() -> None: pather, tool = pather_with_strategy_tool() - pather.jog('A', 4, length=10, strategy='turn_first', marker='sentinel') + pather.jog('A', 4, length=10, strategy='turn_first', tool_options={'marker': 'sentinel'}) assert tool.seen_kwargs assert all('strategy' not in kwargs for kwargs in tool.seen_kwargs) assert any(kwargs.get('marker') == 'sentinel' for kwargs in tool.seen_kwargs) + assert all(step.data['marker'] == 'sentinel' for step in pather._paths['A']) def test_pather_route_strategy_rejects_invalid_values() -> None: diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index f79ecc1..9e5e268 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -252,7 +252,8 @@ def test_pather_trace_into_rejects_reserved_route_kwargs( p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') p.pattern.ports['B'] = dst - with pytest.raises(BuildError, match=match): + _ = match + with pytest.raises(TypeError, match='unexpected keyword argument'): p.trace_into('A', 'B', plug_destination=False, **kwargs) assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0))