From f9611933acfd003076d5f11e27a38c288eca6bba Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 13 Jul 2026 14:44:39 -0700 Subject: [PATCH] [Pather / Tool] more unification work and fixes --- MIGRATION.md | 75 +++- examples/tutorial/pather.py | 16 +- masque/__init__.py | 1 + masque/builder/__init__.py | 6 + masque/builder/_tolerances.py | 48 +++ masque/builder/error.py | 4 + masque/builder/pather.py | 51 +-- masque/builder/planner/bounds.py | 10 +- masque/builder/planner/interface.py | 4 +- masque/builder/planner/planner.py | 93 +++-- masque/builder/tool_testing.py | 368 ++++++++++++++++++++ masque/builder/tools.py | 186 +++++++--- masque/builder/utils.py | 9 +- masque/test/helpers.py | 2 +- masque/test/test_autotool_planning.py | 73 +++- masque/test/test_pather_constraints.py | 43 ++- masque/test/test_pather_core.py | 16 + masque/test/test_pather_primitive_offers.py | 94 ++++- masque/test/test_pather_rendering.py | 60 +++- 19 files changed, 1022 insertions(+), 137 deletions(-) create mode 100644 masque/builder/_tolerances.py create mode 100644 masque/builder/tool_testing.py diff --git a/MIGRATION.md b/MIGRATION.md index 1a54793..3fb545f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -288,7 +288,25 @@ you are exposing: common callback behavior. It is not the normal class users should instantiate. The concrete offer classes carry the semantic fields (`length_domain`, `jog_domain`, `ccw`) so tools do not need to encode primitive identity in -strings. +strings. Each concrete offer now also exposes a class-level canonical `kind`. +Tools must return the matching offer kind for the discovery query; for example, +`primitive_offers('s', ...)` must return only `SOffer` instances. Mismatches are +reported as fatal `ToolContractError`s rather than silently ignored. + +`RenderStep` now stores that same canonical kind. Code that constructs render +steps directly must use the kind rather than a legacy opcode: + +```python +# old +RenderStep('L', tool, start, end, data) + +# new +RenderStep('straight', tool, start, end, data) +``` + +Use `'bend'`, `'s'`, `'u'`, or `'plug'` for the other step types. The read-only +`RenderStep.opcode` and `PrimitiveOffer.opcode` properties remain available for +code that only consumes steps, but opcodes are now derived rather than stored. Minimal straight-only example: @@ -341,9 +359,11 @@ 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 +does this automatically for generated straight and S-bend primitives: the +mapping is deep-copied per offer discovery and passed to its generator as +keyword arguments during rendering. Consequently every AutoTool option value +must be deep-copyable, and later mutation of nested caller-owned values does +not affect a pending route. AutoTool options must not change generated ports or endpoint geometry. `PathTool` defines no tool options and rejects nonempty mappings. @@ -360,9 +380,51 @@ Primitive offers are local planning objects: - `parameterized_bbox` may carry opaque future-router footprint metadata - `commit(parameter)` returns opaque render data consumed later by `render()` - `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton +- domains are validated when an offer is constructed, not when it is first evaluated - selected parameter values must be finite; domains may use infinite open bounds but not `NaN` +- straight/bend domains require a finite nonnegative minimum; fixed singleton domains must be finite - `None` and `"unk"` ptypes are wildcards; concrete ptype mismatches reject an offer +`AutoTool.add_straight(length_range=...)` and +`AutoTool.add_sbend(jog_range=...)` now validate their ranges before metadata +inference. `jog_range` is an absolute-magnitude range and must have a finite, +nonnegative lower bound; AutoTool creates the corresponding positive and +negative S offers itself. Invalid ranges now raise immediately instead of +registering no offers. + +`ToolContractError` is exported from `masque` and `masque.builder`. It marks a +broken Tool contract—such as an endpoint ptype that disagrees with its offer, +an invalid evaluated cost, or rendered output that disagrees with the planned +endpoint—and is fatal to route fallback. Ordinary `BuildError` raised by a +planning callback remains a recoverable candidate rejection. Rendered output +rotations are compared modulo one full turn; a port facing exactly backward is +no longer accepted as equivalent. A `None` rotation remains an explicit +wildcard. + +Custom Tool authors can exercise discovery, offer callbacks, committed data, +and one-step rendering without depending on pytest: + +```python +from masque.builder import ToolContractCase, validate_tool_contract + +validate_tool_contract(my_tool, ( + ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)), + ToolContractCase('bend', in_ptype='wire', ccw=False), + ToolContractCase('bend', in_ptype='wire', ccw=True), + ToolContractCase('s', in_ptype='wire', require_offers=False), +)) +``` + +Cases derive representative parameters from each returned offer domain and +may add explicit probes. Empty discovery is an error unless +`require_offers=False`; bbox support is required only with `check_bbox=True`. +Validation returns normally on success and otherwise raises an +`ExceptionGroup` of contextual `ToolContractError`s. + +Positional routing bounds (`p`, `pos`, `position`, `x`, and `y`) now require a +nearly Manhattan input-port direction. Arbitrarily angled ports remain valid +for non-positional/extension routing. + Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype adapters. Requested `out_ptype` constrains only the final route endpoint; any intermediate ptypes are chosen by the route solver. @@ -717,8 +779,9 @@ These are additive, but available now from `masque` and `masque.builder`: - from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`, `PortsLibraryView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`, `BuildReport`, `CellProvenance`, and `cell` -- from `masque.builder`: `CostCallable`, the concrete primitive-offer classes, - and structured route error/status types +- from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete + primitive-offer classes, structured route error/status types, + `ToolContractCase`, and `validate_tool_contract` ## Minimal migration checklist diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index 7813529..c536428 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -9,7 +9,9 @@ import numpy from numpy import pi from masque import Pather, Library, Pattern, Port, layer_t from masque.abstract import Abstract -from masque.builder import BendOffer, RenderStep, StraightOffer, Tool +from masque.builder import ( + BendOffer, RenderStep, StraightOffer, Tool, ToolContractCase, validate_tool_contract, + ) from masque.error import BuildError from masque.file.gdsii import writefile from masque.library import ILibrary, SINGLE_USE_PREFIX @@ -419,6 +421,18 @@ def prepare_tools() -> tuple[Library, Tool, Tool]: bend = library.abstract('m2_bend'), transitions = via_transitions, ) + + # Custom tools can be checked independently of Pather or pytest. Automatic + # probes cover each offer domain; explicit probes can target useful process + # dimensions. Unsupported primitive families opt out with require_offers=False. + for tool in (M1_tool, M2_tool): + validate_tool_contract(tool, ( + ToolContractCase('straight', in_ptype=tool.ptype, probe_parameters=(10_000,)), + ToolContractCase('bend', in_ptype=tool.ptype, ccw=False), + ToolContractCase('bend', in_ptype=tool.ptype, ccw=True), + ToolContractCase('s', in_ptype=tool.ptype, require_offers=False), + ToolContractCase('u', in_ptype=tool.ptype, require_offers=False), + )) return library, M1_tool, M2_tool diff --git a/masque/__init__.py b/masque/__init__.py index 89b45b0..bc0a466 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -84,6 +84,7 @@ from .ports import ( from .abstract import Abstract as Abstract from .builder import ( Tool as Tool, + ToolContractError as ToolContractError, Pather as Pather, RouteError as RouteError, RouteFailureDetails as RouteFailureDetails, diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 4a92753..a4389f6 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -54,6 +54,7 @@ from .pather import ( PortPather as PortPather, ) from .error import ( + ToolContractError as ToolContractError, RouteError as RouteError, RouteFailureDetails as RouteFailureDetails, RouteOperation as RouteOperation, @@ -61,11 +62,16 @@ from .error import ( MinimumStatus as MinimumStatus, ) from .utils import ell as ell +from .tool_testing import ( + ToolContractCase as ToolContractCase, + validate_tool_contract as validate_tool_contract, +) from .tools import ( Tool as Tool, AutoTool as AutoTool, PathTool as PathTool, RenderStep as RenderStep, + RenderStepKind as RenderStepKind, PrimitiveKind as PrimitiveKind, CostCallable as CostCallable, GeneratedEndpointFn as GeneratedEndpointFn, diff --git a/masque/builder/_tolerances.py b/masque/builder/_tolerances.py new file mode 100644 index 0000000..5f179e3 --- /dev/null +++ b/masque/builder/_tolerances.py @@ -0,0 +1,48 @@ +"""Shared numeric tolerances for builder geometry and parameter comparisons.""" +from math import isclose, remainder, tau +from typing import Any + +import numpy + + +GEOMETRY_RTOL = 1e-5 +GEOMETRY_ATOL = 1e-8 +DOMAIN_RTOL = 1e-9 +DOMAIN_ATOL = 1e-12 +MANHATTAN_ANGLE_RTOL = 1e-9 +MANHATTAN_ANGLE_ATOL = 1e-9 + + +def scalar_close(a: float, b: float) -> bool: + """Match the solver's existing scalar-comparison behavior.""" + return isclose(float(a), float(b), rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL) + + +def array_close(a: Any, b: Any) -> bool: + """Match NumPy's historical builder geometry-comparison behavior.""" + return bool(numpy.allclose(a, b, rtol=GEOMETRY_RTOL, atol=GEOMETRY_ATOL)) + + +def angles_equal(a: float, b: float) -> bool: + """Return true when two rotations are equal modulo one full turn.""" + delta = remainder(float(a) - float(b), tau) + return isclose(delta, 0.0, rel_tol=GEOMETRY_RTOL, abs_tol=GEOMETRY_ATOL) + + +def manhattan_axis(rotation: float) -> int | None: + """Return 0 for horizontal, 1 for vertical, or None for a non-cardinal angle.""" + angle = float(rotation) % (numpy.pi / 2) + if isclose( + angle, + 0.0, + rel_tol=MANHATTAN_ANGLE_RTOL, + abs_tol=MANHATTAN_ANGLE_ATOL, + ) or isclose( + angle, + numpy.pi / 2, + rel_tol=MANHATTAN_ANGLE_RTOL, + abs_tol=MANHATTAN_ANGLE_ATOL, + ): + quarter_turn = round(float(rotation) / (numpy.pi / 2)) + return quarter_turn % 2 + return None diff --git a/masque/builder/error.py b/masque/builder/error.py index 6a647ce..927c03c 100644 --- a/masque/builder/error.py +++ b/masque/builder/error.py @@ -12,6 +12,10 @@ from ..error import BuildError RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] +class ToolContractError(BuildError): + """A Tool returned data inconsistent with its routing contract.""" + + class RouteFailurePolicy(Enum): """Whether route failure may be recovered through alternate/dead planning. diff --git a/masque/builder/pather.py b/masque/builder/pather.py index da1c0d9..15e0c59 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -76,13 +76,14 @@ from .planner.interface import ( RoutePortContext, route_failure_policy, ) -from .error import RouteFailurePolicy +from .error import RouteFailurePolicy, ToolContractError from .planner import ( RouteTieBreakStrategy, RoutingPlanner, ) from .planner.bounds import resolved_position_bound from .logging import PatherLogger +from ._tolerances import angles_equal, array_close logger = logging.getLogger(__name__) @@ -343,7 +344,7 @@ class Pather(PortList): if not steps: continue port = self.ports.get(name, steps[-1].end_port) - prepared.append((name, RenderStep('P', None, port.copy(), port.copy(), None))) + prepared.append((name, RenderStep('plug', None, port.copy(), port.copy(), None))) return prepared def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None: @@ -1130,7 +1131,7 @@ class Pather(PortList): return tool_name = type(batch[0].tool).__name__ - raise BuildError( + raise ToolContractError( f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}' ) @@ -1139,22 +1140,22 @@ class Pather(PortList): actual = pat.ports.get(portspec) tool_name = type(batch[0].tool).__name__ if actual is None: - raise BuildError( + raise ToolContractError( f'Tool {tool_name}.render() did not produce output port {portspec!r}; ' f'expected {expected.describe()}' ) - offsets_match = bool(numpy.allclose(actual.offset, expected.offset)) + offsets_match = array_close(actual.offset, expected.offset) rotations_match = ( actual.rotation is None or expected.rotation is None - or bool(numpy.isclose(numpy.sin(actual.rotation - expected.rotation), 0)) + or angles_equal(actual.rotation, expected.rotation) ) ptypes_match = ptypes_compatible(actual.ptype, expected.ptype) if offsets_match and rotations_match and ptypes_match: return - raise BuildError( + raise ToolContractError( f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: ' f'expected {expected.describe()}, got {actual.describe()}' ) @@ -1186,14 +1187,14 @@ class Pather(PortList): continue batch: list[RenderStep] = [] for step in steps: - appendable = step.opcode in ('L', 'S', 'U') + appendable = step.kind != 'plug' same_tool = batch and step.tool is batch[0].tool if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)): render_batch(portspec, batch, append) batch = [] if appendable: batch.append(step) - elif step.opcode == 'P' and portspec in pat.ports: + elif step.kind == 'plug' and portspec in pat.ports: del pat.ports[portspec] if batch: render_batch(portspec, batch, append) @@ -1285,6 +1286,14 @@ class PortPather: self.pather = pather self.default_spacing = default_spacing + def _single_port(self, action: str) -> str: + """Return the selected port for an exact-one operation.""" + if len(self.ports) != 1: + raise BuildError( + f'Unable to use implicit {action}() with {len(self.ports)} ports; expected exactly one.' + ) + return self.ports[0] + def retool(self, tool: Tool) -> Self: self.pather.retool(tool, self.ports) return self @@ -1545,28 +1554,24 @@ class PortPather: 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.') + port = self._single_port('trace_into') self.pather.trace_into( - self.ports[0], target_port, out_ptype=out_ptype, plug_destination=plug_destination, + port, target_port, out_ptype=out_ptype, plug_destination=plug_destination, thru=thru, strategy=strategy, tool_options=tool_options, ) return self def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' - 'Use the pather or pattern directly to plug multiple ports.') - self.pather.plug(other, {self.ports[0]: other_port}, **kwargs) + port = self._single_port('plug') + self.pather.plug(other, {port: other_port}, **kwargs) return self def plugged(self, other_port: str | Mapping[str, str]) -> Self: if isinstance(other_port, Mapping): self.pather.plugged(dict(other_port)) - elif len(self.ports) > 1: - raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') else: - self.pather.plugged({self.ports[0]: other_port}) + port = self._single_port('plugged') + self.pather.plugged({port: other_port}) return self # @@ -1604,9 +1609,7 @@ class PortPather: """ Rename active ports. """ name_map: dict[str, str | None] if isinstance(name, str): - if len(self.ports) > 1: - raise BuildError('Use a mapping to rename >1 port') - name_map = {self.ports[0]: name} + name_map = {self._single_port('rename'): name} else: name_map = dict(name) self.pather.rename_ports(name_map) @@ -1637,9 +1640,7 @@ class PortPather: def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]: if isinstance(name, str): - if len(self.ports) > 1: - raise BuildError(f'Use a mapping to {action} >1 port') - name_map = {self.ports[0]: name} + name_map = {self._single_port(action): name} else: name_map = dict(name) diff --git a/masque/builder/planner/bounds.py b/masque/builder/planner/bounds.py index 5b4404b..19a47d1 100644 --- a/masque/builder/planner/bounds.py +++ b/masque/builder/planner/bounds.py @@ -25,6 +25,7 @@ from numpy.typing import ArrayLike, NDArray from ...error import BuildError, PortError from ...ports import Port from ...utils import rotation_matrix_2d +from .._tolerances import manhattan_axis from .interface import RoutePortContext @@ -70,8 +71,13 @@ def resolved_position_bound( 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)) - if is_horiz: + axis = manhattan_axis(port.rotation) + if axis is None: + raise BuildError( + 'Positional bounds require a nearly Manhattan port direction; ' + f'got rotation {port.rotation:g}' + ) + if axis == 0: if key == 'y': raise BuildError('Port is horizontal') target = Port((value, port.offset[1]), rotation=None) diff --git a/masque/builder/planner/interface.py b/masque/builder/planner/interface.py index 514779a..5be69c9 100644 --- a/masque/builder/planner/interface.py +++ b/masque/builder/planner/interface.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from ...error import BuildError from ...ports import Port from ..tools import RenderStep, Tool -from ..error import RouteError, RouteFailurePolicy +from ..error import RouteError, RouteFailurePolicy, ToolContractError class RoutePlanningError(BuildError): @@ -34,6 +34,8 @@ class RoutePlanningError(BuildError): def route_failure_policy(err: Exception) -> RouteFailurePolicy: """Return typed route recovery policy, defaulting generic errors to recoverable.""" + if isinstance(err, ToolContractError): + return RouteFailurePolicy.FATAL if isinstance(err, RoutePlanningError): return err.policy if isinstance(err, RouteError): diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 1d5837a..282a835 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -32,7 +32,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, replace from itertools import combinations -from math import cos, isclose as math_isclose, sin +from math import cos, sin from typing import Any, Literal import numpy @@ -50,6 +50,7 @@ from ..tools import ( SOffer, StraightOffer, Tool, + UOffer, ) from ..error import ( MinimumStatus, @@ -57,7 +58,9 @@ from ..error import ( RouteFailureDetails, RouteFailurePolicy, RouteOperation, + ToolContractError, ) +from .._tolerances import scalar_close from ..utils import ell from . import bounds as planner_bounds from .interface import ( @@ -84,7 +87,7 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr def is_close(a: float, b: float) -> bool: """Compare route-solver scalars with the planner tolerance.""" - return math_isclose(float(a), float(b), rel_tol=1e-5, abs_tol=1e-8) + return scalar_close(a, b) def clean_parameter(value: float) -> float: @@ -225,8 +228,6 @@ class SelectedPrimitive: """Finite additive planning cost reported by the offer.""" role: Literal['main', 'adapter'] = 'main' """Whether this step satisfies route geometry or adapts ptype.""" - route_kind: PrimitiveKind | None = None - """Primitive kind used when querying the Tool for this step.""" @dataclass(frozen=True, slots=True) @@ -318,7 +319,7 @@ class Solver: def __init__(self, request: SolverRequest) -> None: self.request = request - self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str, PrimitiveKind | None], SelectedPrimitive] = {} + self.eval_cache: dict[tuple[int, float, str | None, str | None, str, str], SelectedPrimitive] = {} self.offer_cache: dict[ tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]], tuple[PrimitiveOffer, ...], @@ -333,9 +334,9 @@ class Solver: for step in steps: if step.role == 'adapter': continue - if step.route_kind == 'bend': + if step.offer.kind == 'bend': count += 1 - elif step.route_kind in ('s', 'u'): + elif step.offer.kind in ('s', 'u'): count += 2 return count @@ -371,7 +372,6 @@ class Solver: tuple(( offer_key(step.offer), step.role, - step.route_kind, round(float(step.parameter), 9), endpoint_key(step.out_port), ) for step in candidate.steps), @@ -448,16 +448,54 @@ class Solver: kwargs = dict(self.request.tool_options) if extra: kwargs.update(extra) + + def query_tool() -> tuple[PrimitiveOffer, ...]: + expected_offer_type = { + 'straight': StraightOffer, + 'bend': BendOffer, + 's': SOffer, + 'u': UOffer, + }[kind] + offers = self.request.tool.primitive_offers( + kind, + in_ptype=in_ptype, + out_ptype=out_ptype, + **kwargs, + ) + if not isinstance(offers, tuple): + raise ToolContractError( + f'Tool.primitive_offers({kind!r}) must return a tuple, ' + f'got {type(offers).__name__}' + ) + for index, offer in enumerate(offers): + if not isinstance(offer, PrimitiveOffer): + raise ToolContractError( + f'Tool.primitive_offers({kind!r}) item {index} must be a PrimitiveOffer, ' + f'got {type(offer).__name__}' + ) + if offer.kind != kind: + raise ToolContractError( + f'Tool.primitive_offers({kind!r}) item {index} returned ' + f'{offer.kind!r} offer' + ) + if not isinstance(offer, expected_offer_type): + raise ToolContractError( + f'Tool.primitive_offers({kind!r}) item {index} must be ' + f'{expected_offer_type.__name__}, got {type(offer).__name__}' + ) + return offers + + extra_items = tuple(sorted((extra or {}).items())) try: - cache_key = (kind, in_ptype, out_ptype, tuple(sorted(kwargs.items()))) + cache_key = (kind, in_ptype, out_ptype, extra_items) hash(cache_key) except TypeError: - return self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + return query_tool() cached = self.offer_cache.get(cache_key) if cached is not None: return cached - offers = self.request.tool.primitive_offers(kind, in_ptype=in_ptype, out_ptype=out_ptype, **kwargs) + offers = query_tool() self.offer_cache[cache_key] = offers return offers @@ -469,7 +507,6 @@ class Solver: *, out_ptype: str | None, role: Literal['main', 'adapter'], - route_kind: PrimitiveKind | None, route_name: str | None = None, ) -> SelectedPrimitive: """ @@ -481,7 +518,7 @@ class Solver: """ route_name = self.request.route_name if route_name is None else route_name selected = offer.canonicalize_parameter(clean_parameter(parameter)) - key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name, route_kind) + key = (id(offer), round(float(selected), 12), in_ptype, out_ptype, role, route_name) cached = self.eval_cache.get(key) if cached is not None: return cached @@ -491,28 +528,37 @@ class Solver: if isinstance(offer, SOffer) and is_close(selected, 0): raise BuildError('zero-jog S primitive candidates are not allowed') out_port = offer.endpoint_at(selected) + if not isinstance(out_port, Port): + raise ToolContractError( + f'{route_name} primitive endpoint_at() must return a Port, ' + f'got {type(out_port).__name__}' + ) if not ptypes_compatible(out_port.ptype, offer.out_ptype): - raise RoutePlanningError( + raise ToolContractError( f'{route_name} primitive endpoint ptype does not match declared offer out_ptype', - policy=RouteFailurePolicy.FATAL, ) if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype): raise RoutePlanningError( 'Requested out_ptype does not match primitive endpoint ptype', policy=RouteFailurePolicy.FATAL, ) - cost = float(offer.cost_at(selected)) + try: + if type(offer).cost_at is PrimitiveOffer.cost_at: + cost = float(PrimitiveOffer._cost_for_endpoint(offer, selected, out_port)) + else: + cost = float(offer.cost_at(selected)) + except (TypeError, ValueError, OverflowError) as err: + raise ToolContractError(f'{route_name} primitive returned a non-numeric cost') from err if not numpy.isfinite(cost): - raise BuildError(f'{route_name} primitive returned non-finite cost') + raise ToolContractError(f'{route_name} primitive returned non-finite cost') if cost < 0: - raise BuildError(f'{route_name} primitive returned negative cost') + raise ToolContractError(f'{route_name} primitive returned negative cost') primitive = SelectedPrimitive( offer, selected, out_port, cost, role=role, - route_kind=route_kind, ) self.eval_cache[key] = primitive return primitive @@ -532,7 +578,7 @@ class Solver: for step in steps: out_port = step.out_port if out_port.rotation is None: - raise BuildError('Primitive endpoints must have rotation') + raise ToolContractError('Primitive endpoints must have rotation') angle_cos = cos(angle) angle_sin = sin(angle) x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y) @@ -573,7 +619,6 @@ class Solver: current_ptype, out_ptype=None, role='adapter', - route_kind=kind, route_name=f'{kind} adapter', ) except BuildError as err: @@ -606,7 +651,6 @@ class Solver: current_ptype, out_ptype=None, role='main', - route_kind='straight', route_name='trace', ) except BuildError as err: @@ -642,7 +686,6 @@ class Solver: current_ptype, out_ptype=None, role='main', - route_kind='bend', route_name='trace', ) except BuildError as err: @@ -678,7 +721,6 @@ class Solver: current_ptype, out_ptype=None, role='main', - route_kind=kind, route_name=route_name, ) except BuildError as err: @@ -814,7 +856,6 @@ class Solver: current_ptype, out_ptype=None, role=step.role, - route_kind=step.route_kind, ) selected.append(selected_step) current_ptype = selected_step.out_port.ptype @@ -1183,7 +1224,7 @@ class RoutingPlanner: out_port.rotate_around((0, 0), pi + port_rot) out_port.translate(current.offset) render_steps.append(RenderStep( - selected.offer.opcode, + selected.offer.kind, leg.tool, current.copy(), out_port.copy(), diff --git a/masque/builder/tool_testing.py b/masque/builder/tool_testing.py new file mode 100644 index 0000000..44daf95 --- /dev/null +++ b/masque/builder/tool_testing.py @@ -0,0 +1,368 @@ +"""Pytest-independent contract checks for custom routing Tools.""" +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from math import isfinite +from types import MappingProxyType +from typing import TYPE_CHECKING, Any + +import numpy +from numpy import pi + +from ..library import ILibrary, SINGLE_USE_PREFIX +from ..ports import Port +from ..utils import ptypes_compatible +from ._tolerances import angles_equal, array_close, scalar_close +from .error import ToolContractError +from .tools import ( + BendOffer, PrimitiveKind, PrimitiveOffer, RenderStep, SOffer, StraightOffer, Tool, UOffer, + ) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +_RESERVED_OPTION_KEYS = frozenset(('kind', 'in_ptype', 'out_ptype', 'ccw')) + + +@dataclass(frozen=True, slots=True) +class ToolContractCase: + """One primitive-discovery query to exercise against a custom Tool.""" + + kind: PrimitiveKind + in_ptype: str | None = None + out_ptype: str | None = None + ccw: bool | None = None + tool_options: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({})) + probe_parameters: tuple[float, ...] = () + require_offers: bool = True + check_bbox: bool = False + label: str | None = None + + def __post_init__(self) -> None: + if self.kind not in ('straight', 'bend', 's', 'u'): + raise ValueError(f'Unrecognized primitive kind {self.kind!r}') + if self.kind == 'bend': + if self.ccw is None: + raise ValueError('Bend ToolContractCase requires ccw') + elif self.ccw is not None: + raise ValueError('ccw is only valid for bend ToolContractCase') + + try: + options = deepcopy(dict(self.tool_options)) + except Exception as err: + raise ValueError('ToolContractCase.tool_options must be a deep-copyable mapping') from err + nonstring = [key for key in options if not isinstance(key, str)] + if nonstring: + raise ValueError(f'ToolContractCase.tool_options keys must be strings; got {nonstring!r}') + collisions = sorted(_RESERVED_OPTION_KEYS & options.keys()) + if collisions: + raise ValueError(f'ToolContractCase.tool_options contains reserved keys: {", ".join(collisions)}') + + try: + probes = tuple(float(value) for value in self.probe_parameters) + except (TypeError, ValueError, OverflowError) as err: + raise ValueError('ToolContractCase.probe_parameters must contain numeric scalars') from err + if not all(isfinite(value) for value in probes): + raise ValueError('ToolContractCase.probe_parameters must be finite') + + object.__setattr__(self, 'ccw', None if self.ccw is None else bool(self.ccw)) + object.__setattr__(self, 'tool_options', MappingProxyType(options)) + object.__setattr__(self, 'probe_parameters', probes) + + +def _automatic_probes(offer: PrimitiveOffer) -> tuple[float, ...]: + """Choose deterministic representative parameters inside one offer domain.""" + lower, upper = (float(value) for value in offer.parameter_domain) + if lower == upper: + return (lower,) + if numpy.isfinite(lower) and numpy.isfinite(upper): + midpoint = lower / 2 + upper / 2 + if midpoint == upper: + midpoint = float(numpy.nextafter(upper, lower)) + return (lower, midpoint) + if numpy.isfinite(lower): + step = max(1.0, abs(lower) * 0.1) + return (lower, lower + step) + if numpy.isfinite(upper): + step = max(1.0, abs(upper) * 0.1) + return (upper - step, upper - 2 * step) + return (-1.0, 1.0) + + +def _offer_probes(offer: PrimitiveOffer, extras: Sequence[float]) -> tuple[float, ...]: + """Combine automatic and applicable explicit probes without duplicates.""" + probes: list[float] = [] + for parameter in (*_automatic_probes(offer), *extras): + try: + selected = offer.canonicalize_parameter(parameter) + except Exception: + continue + if not any(scalar_close(selected, previous) for previous in probes): + probes.append(selected) + return tuple(probes) + + +def _offer_metadata(offer: PrimitiveOffer) -> tuple[Any, ...]: + """Return discovery metadata that must remain stable across repeated queries.""" + cost_policy: tuple[str, Any] + if callable(offer.cost): + cost_policy = ('callable', type(offer.cost).__qualname__) + else: + cost_policy = ('factor', float(offer.cost)) + return ( + type(offer), + offer.kind, + offer.in_ptype, + offer.out_ptype, + tuple(float(value) for value in offer.parameter_domain), + getattr(offer, 'ccw', None), + cost_policy, + ) + + +def _evaluated_cost(offer: PrimitiveOffer, parameter: float, endpoint: Port) -> float: + """Mirror the solver's one-endpoint base-cost path while honoring overrides.""" + if type(offer).cost_at is PrimitiveOffer.cost_at: + return PrimitiveOffer._cost_for_endpoint(offer, parameter, endpoint) + return float(offer.cost_at(parameter)) + + +def validate_tool_contract(tool: Tool, cases: Sequence[ToolContractCase]) -> None: + """Validate Tool discovery, offer callbacks, and one-step rendering. + + All independent violations are collected and raised as one + `ExceptionGroup` containing contextual `ToolContractError` instances. + """ + cases = tuple(cases) + if not cases: + raise ValueError('validate_tool_contract() requires at least one case') + + errors: list[ToolContractError] = [] + + def violation(context: str, message: str, cause: Exception | None = None) -> None: + err = ToolContractError(f'{context}: {message}') + if cause is not None: + err.__cause__ = cause + errors.append(err) + + def discover(case: ToolContractCase, context: str, repetition: str) -> tuple[PrimitiveOffer, ...] | None: + expected_offer_type = { + 'straight': StraightOffer, + 'bend': BendOffer, + 's': SOffer, + 'u': UOffer, + }[case.kind] + try: + kwargs = deepcopy(dict(case.tool_options)) + if case.kind == 'bend': + kwargs['ccw'] = case.ccw + offers = tool.primitive_offers( + case.kind, + in_ptype=case.in_ptype, + out_ptype=case.out_ptype, + **kwargs, + ) + except Exception as err: + violation(context, f'{repetition} discovery raised {type(err).__name__}: {err}', err) + return None + + if not isinstance(offers, tuple): + violation(context, f'{repetition} discovery returned {type(offers).__name__}, expected tuple') + return None + valid = True + for offer_index, offer in enumerate(offers): + if not isinstance(offer, PrimitiveOffer): + violation( + context, + f'{repetition} discovery item {offer_index} is {type(offer).__name__}, expected PrimitiveOffer', + ) + valid = False + elif offer.kind != case.kind: + violation( + context, + f'{repetition} discovery item {offer_index} has kind {offer.kind!r}, expected {case.kind!r}', + ) + valid = False + elif not isinstance(offer, expected_offer_type): + violation( + context, + f'{repetition} discovery item {offer_index} is {type(offer).__name__}, ' + f'expected {expected_offer_type.__name__}', + ) + valid = False + return offers if valid else None + + for case_index, case in enumerate(cases): + context = case.label or f'case {case_index} ({case.kind})' + first = discover(case, context, 'first') + second = discover(case, context, 'repeated') + if first is None or second is None: + continue + if case.require_offers and not first: + violation(context, 'discovery returned no offers') + if len(first) != len(second): + violation(context, f'discovery count changed from {len(first)} to {len(second)}') + + matched_explicit = [False] * len(case.probe_parameters) + for offer_index, offer in enumerate(first): + offer_context = f'{context}, offer {offer_index}' + repeated = second[offer_index] if offer_index < len(second) else None + if repeated is not None and _offer_metadata(offer) != _offer_metadata(repeated): + violation(offer_context, 'discovery metadata changed between repeated queries') + + probes = _offer_probes(offer, case.probe_parameters) + for explicit_index, parameter in enumerate(case.probe_parameters): + try: + offer.canonicalize_parameter(parameter) + except Exception: + continue + matched_explicit[explicit_index] = True + + stable_ptype: str | None = None + stable_rotation: float | None = None + has_stable_endpoint = False + for parameter in probes: + probe_context = f'{offer_context}, parameter {parameter:g}' + try: + endpoint = offer.endpoint_at(parameter) + except Exception as err: + violation(probe_context, f'endpoint_at() raised {type(err).__name__}: {err}', err) + continue + if not isinstance(endpoint, Port): + violation(probe_context, f'endpoint_at() returned {type(endpoint).__name__}, expected Port') + continue + if not numpy.all(numpy.isfinite(endpoint.offset)): + violation(probe_context, 'endpoint offset must be finite') + if endpoint.rotation is None or not numpy.isfinite(endpoint.rotation): + violation(probe_context, 'endpoint rotation must be finite and specified') + if not ptypes_compatible(endpoint.ptype, offer.out_ptype): + violation(probe_context, 'endpoint ptype does not match declared out_ptype') + + if offer.kind in ('straight', 'bend') and not scalar_close(endpoint.x, parameter): + violation(probe_context, 'straight/bend endpoint x must equal its parameter') + if offer.kind in ('s', 'u') and not scalar_close(endpoint.y, parameter): + violation(probe_context, 'S/U endpoint y must equal its parameter') + expected_rotation = { + 'straight': pi, + 'bend': -pi / 2 if isinstance(offer, BendOffer) and offer.ccw else pi / 2, + 's': pi, + 'u': 0.0, + }[offer.kind] + if endpoint.rotation is not None and not angles_equal(endpoint.rotation, expected_rotation): + violation( + probe_context, + f'endpoint rotation does not match {offer.kind!r} geometry', + ) + + if has_stable_endpoint: + if endpoint.ptype != stable_ptype: + violation(probe_context, 'endpoint ptype changes across the offer domain') + if ( + endpoint.rotation is None + or stable_rotation is None + or not angles_equal(endpoint.rotation, stable_rotation) + ): + violation(probe_context, 'endpoint rotation changes across the offer domain') + else: + stable_ptype = endpoint.ptype + stable_rotation = endpoint.rotation + has_stable_endpoint = True + + try: + cost = float(_evaluated_cost(offer, parameter, endpoint)) + if not numpy.isfinite(cost) or cost < 0: + violation(probe_context, f'cost must be finite and nonnegative, got {cost!r}') + except Exception as err: + violation(probe_context, f'cost_at() raised {type(err).__name__}: {err}', err) + cost = None + + if repeated is not None: + try: + repeated_endpoint = repeated.endpoint_at(parameter) + repeated_cost = float(_evaluated_cost(repeated, parameter, repeated_endpoint)) + if ( + not isinstance(repeated_endpoint, Port) + or not array_close(repeated_endpoint.offset, endpoint.offset) + or repeated_endpoint.ptype != endpoint.ptype + or repeated_endpoint.rotation is None + or endpoint.rotation is None + or not angles_equal(repeated_endpoint.rotation, endpoint.rotation) + ): + violation(probe_context, 'endpoint result changed after repeated discovery') + if cost is not None and not scalar_close(repeated_cost, cost): + violation(probe_context, 'cost result changed after repeated discovery') + except Exception as err: + violation( + probe_context, + f'repeated offer evaluation raised {type(err).__name__}: {err}', + err, + ) + + if case.check_bbox: + try: + offer.bbox_at(parameter) + except Exception as err: + violation(probe_context, f'bbox_at() raised {type(err).__name__}: {err}', err) + + try: + data = offer.commit(parameter) + except Exception as err: + violation(probe_context, f'commit() raised {type(err).__name__}: {err}', err) + continue + + try: + start = Port((0, 0), rotation=pi, ptype=offer.in_ptype or 'unk') + tree = tool.render((RenderStep(offer.kind, tool, start, endpoint.copy(), data),)) + except Exception as err: + violation(probe_context, f'render() raised {type(err).__name__}: {err}', err) + continue + if not isinstance(tree, ILibrary): + violation(probe_context, f'render() returned {type(tree).__name__}, expected ILibrary') + continue + try: + top_name = tree.top() + pattern = tree.top_pattern() + except Exception as err: + violation(probe_context, f'rendered tree has no valid top cell: {err}', err) + continue + missing = sorted( + name + for name in tree.dangling_refs(top_name) + if isinstance(name, str) and name.startswith(SINGLE_USE_PREFIX) + ) + if missing: + violation(probe_context, f'rendered tree has missing single-use refs: {missing}') + missing_ports = [name for name in ('A', 'B') if name not in pattern.ports] + if missing_ports: + violation(probe_context, f'rendered top cell is missing ports: {missing_ports}') + continue + input_port, output_port = pattern.ports['A'], pattern.ports['B'] + if not ptypes_compatible(input_port.ptype, offer.in_ptype): + violation(probe_context, 'rendered input ptype does not match offer in_ptype') + try: + rendered_offset, rendered_rotation = input_port.measure_travel(output_port) + except Exception as err: + violation(probe_context, f'unable to measure rendered endpoint: {err}', err) + continue + if not array_close(rendered_offset, endpoint.offset): + violation(probe_context, 'rendered output offset does not match planned endpoint') + if ( + rendered_rotation is None + or endpoint.rotation is None + or not angles_equal(rendered_rotation, endpoint.rotation) + ): + violation(probe_context, 'rendered output rotation does not match planned endpoint') + if not ptypes_compatible(output_port.ptype, endpoint.ptype): + violation(probe_context, 'rendered output ptype does not match planned endpoint') + + for parameter, matched in zip(case.probe_parameters, matched_explicit, strict=True): + if not matched: + violation(context, f'explicit probe {parameter:g} is outside every discovered offer domain') + + if errors: + raise ExceptionGroup( + f'{type(tool).__name__} failed Tool contract validation with {len(errors)} violation(s)', + errors, + ) diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 588c8f0..125140e 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -45,10 +45,11 @@ search across Tools or infer `Pather.retool()` boundaries; transitions, cross-ptype routes, and adapter shapes must be exposed by the active Tool as primitive offers. """ -from typing import Literal, Any, Self +from typing import Literal, Any, ClassVar, Self from collections import ChainMap from collections.abc import Sequence, Callable, Mapping from abc import ABC, abstractmethod +from copy import deepcopy 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 @@ -68,14 +69,16 @@ from ..abstract import Abstract from ..library import ILibrary, Library, SINGLE_USE_PREFIX from ..error import BuildError from ..shapes import Path +from ._tolerances import DOMAIN_ATOL, DOMAIN_RTOL, array_close, angles_equal +from .error import ToolContractError def _canonicalize_domain_value( value: float, domain: tuple[float, float], *, - rtol: float = 1e-9, - atol: float = 1e-12, + rtol: float = DOMAIN_RTOL, + atol: float = DOMAIN_ATOL, ) -> float: """ Canonicalize a solved primitive parameter against a route domain. @@ -107,6 +110,29 @@ def _canonicalize_domain_value( return vv +def _validated_parameter_domain( + domain: tuple[float, float], + *, + name: str = 'Parameter domain', + nonnegative_minimum: bool = False, + ) -> tuple[float, float]: + """Validate and normalize a primitive parameter domain at construction time.""" + try: + lower_raw, upper_raw = domain + lower, upper = float(lower_raw), float(upper_raw) + except (TypeError, ValueError, OverflowError) as err: + raise BuildError(f'{name} must be a pair of numeric bounds') from err + if scalar_isnan(lower) or scalar_isnan(upper): + raise BuildError(f'{name} must not contain NaN values: {domain}') + if lower > upper: + raise BuildError(f'{name} lower bound must not exceed upper bound: {domain}') + if lower == upper and not scalar_isfinite(lower): + raise BuildError(f'{name} singleton must be finite: {domain}') + if nonnegative_minimum and (not scalar_isfinite(lower) or lower < 0): + raise BuildError(f'{name} must have a finite, nonnegative minimum: {domain}') + return lower, upper + + EndpointCallable = Callable[[float], Port] CommitCallable = Callable[[float], Any] BBoxCallable = Callable[[float], NDArray[numpy.float64]] @@ -114,6 +140,21 @@ DataCallable = Callable[[float], Any] BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]] CostCallable = Callable[[float, Port], float] PrimitiveKind = Literal['straight', 'bend', 's', 'u'] +RenderStepKind = PrimitiveKind | Literal['plug'] +RenderOpcode = Literal['L', 'S', 'U', 'P'] + + +def _opcode_for_kind(kind: RenderStepKind) -> RenderOpcode: + """Return the legacy render opcode derived from one canonical kind.""" + if kind in ('straight', 'bend'): + return 'L' + if kind == 's': + return 'S' + if kind == 'u': + return 'U' + if kind == 'plug': + return 'P' + raise BuildError(f'Unrecognized render-step kind {kind!r}') def _validated_cost(cost: CostCallable | float) -> CostCallable | float: @@ -130,13 +171,13 @@ def _validated_cost(cost: CostCallable | float) -> CostCallable | float: def _validated_cost_value(value: Any) -> float: - """Return a canonical evaluated cost or raise `BuildError`.""" + """Return a canonical evaluated cost or raise `ToolContractError`.""" try: cost = float(value) except (TypeError, ValueError, OverflowError) as err: - raise BuildError(f'Primitive cost must be a number, got {value!r}') from err + raise ToolContractError(f'Primitive cost must be a number, got {value!r}') from err if not scalar_isfinite(cost) or cost < 0: - raise BuildError(f'Primitive cost must be nonnegative and finite, got {cost:g}') + raise ToolContractError(f'Primitive cost must be nonnegative and finite, got {cost:g}') return cost @@ -218,6 +259,7 @@ class PrimitiveOffer(ABC): """Reserved footprint metadata; the current solver does not inspect it.""" endpoint_planner: EndpointCallable | None = None commit_planner: CommitCallable | None = None + kind: ClassVar[PrimitiveKind] def __post_init__(self) -> None: has_endpoint = self.endpoint_planner is not None @@ -225,12 +267,18 @@ class PrimitiveOffer(ABC): if has_endpoint != has_commit: raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner') + _validated_parameter_domain( + self.parameter_domain, + nonnegative_minimum=self.kind in ('straight', 'bend'), + ) object.__setattr__(self, 'cost', _validated_cost(self.cost)) @property - @abstractmethod def opcode(self) -> Literal['L', 'S', 'U']: - raise NotImplementedError + """Legacy render opcode derived from this offer's canonical kind.""" + opcode = _opcode_for_kind(self.kind) + assert opcode != 'P' + return opcode @property @abstractmethod @@ -266,8 +314,12 @@ class PrimitiveOffer(ABC): out_port = self.endpoint_planner(selected) else: out_port = self.endpoint_at(selected) + return self._cost_for_endpoint(selected, out_port) + + def _cost_for_endpoint(self, parameter: float, out_port: Port) -> float: + """Evaluate the base cost policy using an endpoint already produced by planning.""" if callable(self.cost): - value = self.cost(selected, out_port) + value = self.cost(parameter, out_port) else: default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y)) value = self.cost * default_cost @@ -315,6 +367,7 @@ class PrimitiveOffer(ABC): @dataclass(frozen=True, slots=True) class StraightOffer(PrimitiveOffer): """Straight or straight-like primitive parameterized by public route length.""" + kind: ClassVar[Literal['straight']] = 'straight' length_domain: tuple[float, float] = (0.0, numpy.inf) @classmethod @@ -377,10 +430,6 @@ class StraightOffer(PrimitiveOffer): length_domain = (float(endpoint.x), float(endpoint.x)), ) - @property - def opcode(self) -> Literal['L']: - return 'L' - @property def parameter_domain(self) -> tuple[float, float]: return self.length_domain @@ -389,6 +438,7 @@ class StraightOffer(PrimitiveOffer): @dataclass(frozen=True, slots=True) class BendOffer(PrimitiveOffer): """Single-turn L-route primitive parameterized by public route length.""" + kind: ClassVar[Literal['bend']] = 'bend' ccw: bool = True length_domain: tuple[float, float] = (0.0, numpy.inf) @@ -454,10 +504,6 @@ class BendOffer(PrimitiveOffer): length_domain = (float(endpoint.x), float(endpoint.x)), ) - @property - def opcode(self) -> Literal['L']: - return 'L' - @property def parameter_domain(self) -> tuple[float, float]: return self.length_domain @@ -466,6 +512,7 @@ class BendOffer(PrimitiveOffer): @dataclass(frozen=True, slots=True) class SOffer(PrimitiveOffer): """Non-turning S-route primitive parameterized by jog for a fixed route length.""" + kind: ClassVar[Literal['s']] = 's' jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) @classmethod @@ -526,10 +573,6 @@ class SOffer(PrimitiveOffer): jog_domain = (float(endpoint.y), float(endpoint.y)), ) - @property - def opcode(self) -> Literal['S']: - return 'S' - @property def parameter_domain(self) -> tuple[float, float]: return self.jog_domain @@ -538,6 +581,7 @@ class SOffer(PrimitiveOffer): @dataclass(frozen=True, slots=True) class UOffer(PrimitiveOffer): """U-turn-like primitive parameterized by jog for a fixed route length.""" + kind: ClassVar[Literal['u']] = 'u' jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf) @classmethod @@ -598,10 +642,6 @@ class UOffer(PrimitiveOffer): jog_domain = (float(endpoint.y), float(endpoint.y)), ) - @property - def opcode(self) -> Literal['U']: - return 'U' - @property def parameter_domain(self) -> tuple[float, float]: return self.jog_domain @@ -616,16 +656,11 @@ class RenderStep: passes batches of compatible steps to `Tool.render()` when `Pather.render()` is called. """ - opcode: Literal['L', 'S', 'U', 'P'] - """ What operation is being performed. - L: straight or single-bend primitive - S: S-like primitive - U: U-like primitive - P: plug - """ + kind: RenderStepKind + """Canonical primitive kind, or `plug` for an assembly boundary.""" tool: 'Tool | None' - """ Tool that produced this step, or `None` for `opcode='P'`. """ + """Tool that produced this step, or `None` for `kind='plug'`.""" start_port: Port """ Input-side port before this step is rendered. """ @@ -637,18 +672,28 @@ class RenderStep: """ Arbitrary tool-specific data""" def __post_init__(self) -> None: - if self.opcode != 'P' and self.tool is None: - raise BuildError('Got tool=None but the opcode is not "P"') + if self.kind not in ('straight', 'bend', 's', 'u', 'plug'): + raise BuildError(f'Unrecognized RenderStep kind {self.kind!r}') + if self.kind == 'plug': + if self.tool is not None: + raise BuildError('RenderStep kind="plug" requires tool=None') + elif self.tool is None: + raise BuildError('RenderStep requires a Tool unless kind="plug"') + + @property + def opcode(self) -> RenderOpcode: + """Legacy opcode derived from the canonical render-step kind.""" + return _opcode_for_kind(self.kind) def is_continuous_with(self, other: 'RenderStep') -> bool: """ Check if another RenderStep can be appended to this one. """ # Check continuity with tolerance - offsets_match = bool(numpy.allclose(other.start_port.offset, self.end_port.offset)) + offsets_match = array_close(other.start_port.offset, self.end_port.offset) rotations_match = (other.start_port.rotation is None and self.end_port.rotation is None) or ( other.start_port.rotation is not None and self.end_port.rotation is not None and - bool(numpy.isclose(other.start_port.rotation, self.end_port.rotation)) + angles_equal(other.start_port.rotation, self.end_port.rotation) ) return offsets_match and rotations_match @@ -664,7 +709,7 @@ class RenderStep: pp.translate(translation) return RenderStep( - opcode = self.opcode, + kind = self.kind, tool = self.tool, start_port = new_start, end_port = new_end, @@ -682,7 +727,7 @@ class RenderStep: new_end.flip_across(axis=axis) return RenderStep( - opcode = self.opcode, + kind = self.kind, tool = self.tool, start_port = new_start, end_port = new_end, @@ -723,6 +768,10 @@ class Tool(ABC): - `'s'`: a non-turning `SOffer` - `'u'`: an `UOffer` + Every returned offer must have the requested canonical `kind` and use + its corresponding concrete offer class. Contract mismatches are fatal + rather than recoverable candidate rejection. + Other `kwargs` come only from the Pather call's explicit `tool_options` mapping. Tools are responsible for validating the keys they support. @@ -1013,6 +1062,11 @@ class AutoTool(Tool): two-port straight. Metadata inference does not receive route options. """ cost = _validated_cost(cost) + length_range = _validated_parameter_domain( + length_range, + name='Straight length_range', + nonnegative_minimum=True, + ) ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name) def data_at(length: float) -> AutoTool.GeneratedData: @@ -1055,25 +1109,37 @@ class AutoTool(Tool): in_port = abstract.ports[in_port_name] out_port = abstract.ports[out_port_name] - out_ptype = out_port.ptype source_clockwise = self._bend_clockwise(in_port, out_port) if clockwise is not None and bool(clockwise) != source_clockwise: raise BuildError('Bend clockwise argument does not match port orientations') for ccw in (False, True): target_clockwise = not bool(ccw) - bend_dxy, bend_angle = self._bend2dxy(in_port, out_port, source_clockwise, target_clockwise) + source_matches_target = source_clockwise == target_clockwise + if source_matches_target or mirror: + entry_port_name = in_port_name + entry_port, exit_port = in_port, out_port + geometry_clockwise = source_clockwise + else: + entry_port_name = out_port_name + entry_port, exit_port = out_port, in_port + geometry_clockwise = self._bend_clockwise(entry_port, exit_port) + + bend_dxy, bend_angle = self._bend2dxy( + entry_port, + exit_port, + geometry_clockwise, + target_clockwise, + ) bend_dx = float(bend_dxy[0]) bend_dy = float(bend_dxy[1]) - source_matches_target = source_clockwise == target_clockwise mirrored = mirror and not source_matches_target - port_name = in_port_name if (mirror or source_matches_target) else out_port_name - reusable_data = self.ReusableData(abstract, port_name, mirrored) - endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=out_ptype) + reusable_data = self.ReusableData(abstract, entry_port_name, mirrored) + endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=exit_port.ptype) self._bend_offers[int(ccw)].append(BendOffer.prebuilt( - in_ptype = in_port.ptype, - out_ptype = out_ptype, + in_ptype = entry_port.ptype, + out_ptype = exit_port.ptype, endpoint = endpoint, data = reusable_data, ccw = ccw, @@ -1105,6 +1171,11 @@ class AutoTool(Tool): Metadata inference and endpoint planning do not receive route options. """ cost = _validated_cost(cost) + jog_range = _validated_parameter_domain( + jog_range, + name='S-bend jog_range', + nonnegative_minimum=True, + ) ptype, in_port_name, out_port_name = self._infer_sbend_metadata( fn, jog_range, @@ -1345,9 +1416,11 @@ class AutoTool(Tool): @staticmethod def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]: - lower, upper = (float(magnitude_range[0]), float(magnitude_range[1])) - if lower < 0 or lower > upper: - return () + lower, upper = _validated_parameter_domain( + magnitude_range, + name='S-bend jog_range', + nonnegative_minimum=True, + ) if lower == upper: if lower == 0: @@ -1374,7 +1447,10 @@ class AutoTool(Tool): ) -> tuple[PrimitiveOffer, ...]: _ = out_ptype ccw = kwargs.pop('ccw', None) - tool_options = MappingProxyType(dict(kwargs)) + try: + tool_options = MappingProxyType(deepcopy(dict(kwargs))) + except Exception as err: + raise BuildError('AutoTool tool_options must be deep-copyable') from err def configured(offer: PrimitiveOffer) -> PrimitiveOffer: if not tool_options: @@ -1652,12 +1728,12 @@ class PathTool(Tool): transform = rotation_matrix_2d(port_rot + pi) delta = step.end_port.offset - step.start_port.offset local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta - if step.opcode == 'L': + if step.kind in ('straight', 'bend'): local_vertices = self._plan_l_vertices(float(local_end[0]), float(local_end[1])) - elif step.opcode == 'S': + elif step.kind == 's': local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1])) else: - raise BuildError(f'Unrecognized opcode "{step.opcode}"') + raise BuildError(f'Unsupported PathTool render kind {step.kind!r}') for vertex in local_vertices[1:]: path_vertices.append(step.start_port.offset + transform @ vertex) @@ -1666,6 +1742,6 @@ class PathTool(Tool): pat.path(layer=self.layer, width=self.width, vertices=path_vertices) pat.ports = { port_names[0]: local_batch[0].start_port.copy().rotate(pi), - port_names[1]: local_batch[-1].end_port.copy().rotate(pi), + port_names[1]: local_batch[-1].end_port.copy(), } return tree diff --git a/masque/builder/utils.py b/masque/builder/utils.py index e8f06a7..fab28fd 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -8,6 +8,7 @@ from numpy.typing import ArrayLike, NDArray from ..utils import rotation_matrix_2d, SupportsBool from ..error import BuildError +from ._tolerances import manhattan_axis if TYPE_CHECKING: from ..ports import Port @@ -135,7 +136,13 @@ def ell( 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) + axis = manhattan_axis(float(rotations[0])) + if bound_type in _POSITION_BOUND_TYPES and axis is None: + raise BuildError( + 'Positional bounds require a nearly Manhattan port direction; ' + f'got rotation {rotations[0]:g}' + ) + is_horizontal = axis == 0 if bound_type in ('ymin', 'ymax') and is_horizontal: raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!') if bound_type in ('xmin', 'xmax') and not is_horizontal: diff --git a/masque/test/helpers.py b/masque/test/helpers.py index 2ccea1a..9f69933 100644 --- a/masque/test/helpers.py +++ b/masque/test/helpers.py @@ -108,7 +108,7 @@ def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> N """ Assert a simple render-step bend budget for route signatures. """ - bend_count = sum(1 for step in pather._paths[portspec] if step.opcode == 'L' and step.start_port.rotation != step.end_port.rotation) + bend_count = sum(1 for step in pather._paths[portspec] if step.kind == 'bend') assert bend_count <= max_bends diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py index a311985..a0979ce 100644 --- a/masque/test/test_autotool_planning.py +++ b/masque/test/test_autotool_planning.py @@ -2,6 +2,7 @@ from contextlib import suppress from typing import Any import pytest +import numpy from numpy import pi from numpy.testing import assert_allclose @@ -58,7 +59,7 @@ def rendered_offer_tree( ) -> Library: start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk") end, data = commit_offer(offer, parameter) - return tool.render((RenderStep(offer.opcode, tool, start, end, data),)) + return tool.render((RenderStep(offer.kind, tool, start, end, data),)) def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern: @@ -375,7 +376,7 @@ def wildcard_transition_tool() -> tuple[AutoTool, Library]: tool = ( AutoTool(bbox_library=lib) .add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) - .add_sbend(make_core_sbend, "core", "A", "B", jog_range=(-1e8, 1e8)) + .add_sbend(make_core_sbend, "core", "A", "B", jog_range=(0, 1e8)) .add_transition(lib.abstract("wild_core"), "WILD", "CORE") ) return tool, lib @@ -684,6 +685,24 @@ def test_autotool_add_bend_inferred_names_allow_rotational_reuse_without_mirror( assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "wire") +def test_autotool_add_bend_reverse_reuse_swaps_cross_ptypes() -> None: + lib = Library() + bend = make_bend(2, ptype="core", clockwise=True) + bend.ports["B"].ptype = "external" + lib["bend"] = bend + + tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend"), mirror=False) + + cw_offer = tool.primitive_offers("bend", in_ptype="core", ccw=False)[0] + ccw_offer = tool.primitive_offers("bend", in_ptype="external", ccw=True)[0] + assert (cw_offer.in_ptype, cw_offer.out_ptype) == ("core", "external") + assert (ccw_offer.in_ptype, ccw_offer.out_ptype) == ("external", "core") + assert cw_offer.commit(2).port_name == "A" + assert ccw_offer.commit(2).port_name == "B" + assert_rendered_offer_endpoint_matches_plan(tool, cw_offer, 2, "core") + assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "external") + + def test_autotool_add_bend_rejects_clockwise_mismatch() -> None: lib = Library() lib["bend"] = make_bend(2, ptype="wire", clockwise=True) @@ -966,6 +985,28 @@ def test_autotool_validates_cost_before_registering_any_offers() -> None: AutoTool().add_sbend(unused_sbend, jog_range=(-1, 1), cost=-1) +@pytest.mark.parametrize('length_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)]) +def test_autotool_rejects_invalid_straight_range_before_sampling( + length_range: tuple[float, float], + ) -> None: + def unused_straight(_length: float) -> Pattern: + raise AssertionError('invalid range should be rejected before metadata inference') + + with pytest.raises(BuildError, match='Straight length_range'): + AutoTool().add_straight(unused_straight, length_range=length_range) + + +@pytest.mark.parametrize('jog_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)]) +def test_autotool_rejects_invalid_sbend_range_before_sampling( + jog_range: tuple[float, float], + ) -> None: + def unused_sbend(_jog: float) -> Pattern: + raise AssertionError('invalid range should be rejected before metadata inference') + + with pytest.raises(BuildError, match='S-bend jog_range'): + AutoTool().add_sbend(unused_sbend, jog_range=jog_range) + + def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None: tool = make_sbend_tool((4, 4)) offers = tool.primitive_offers('s', in_ptype="core") @@ -982,9 +1023,8 @@ def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None: def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None: - tool = make_sbend_tool((-4, 4)) - - assert tool.primitive_offers('s', in_ptype="core") == () + with pytest.raises(BuildError, match='finite, nonnegative minimum'): + make_sbend_tool((-4, 4)) def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None: @@ -1107,7 +1147,12 @@ def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, L def test_autotool_generated_primitives_snapshot_route_options() -> None: markers: list[str | None] = [] - def make_marked_straight(length: float, marker: str | None = None) -> Pattern: + def make_marked_straight( + length: float, + marker: str | None = None, + nested: dict[str, list[int]] | None = None, + ) -> Pattern: + _ = nested markers.append(marker) return make_straight(length, ptype="wire") @@ -1115,15 +1160,16 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None: p = Pather(Library(), tools=tool, render='deferred') p.ports["A"] = Port((0, 0), 0, ptype="wire") - first_options = {'marker': 'first'} + first_options = {'marker': 'first', 'nested': {'values': [1]}} p.straight("A", 5, tool_options=first_options) first_options['marker'] = 'mutated' + first_options['nested']['values'].append(2) 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(first_data.tool_options) == {'marker': 'first', 'nested': {'values': [1]}} assert dict(second_data.tool_options) == {'marker': 'second'} p.render() @@ -1131,6 +1177,17 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None: assert markers == ['first', 'second'] +def test_autotool_route_options_must_be_deepcopyable() -> None: + class NotCopyable: + def __deepcopy__(self, memo: dict[int, Any]) -> None: + _ = memo + raise TypeError('no copy') + + tool = AutoTool().add_straight(make_straight, 'wire', 'A') + with pytest.raises(BuildError, match='must be deep-copyable'): + tool.primitive_offers('straight', marker=NotCopyable()) + + def test_autotool_route_options_do_not_attach_to_reusable_bends( multi_bend_tool: tuple[AutoTool, Library], ) -> None: diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index 56be6ff..30e3762 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -6,7 +6,9 @@ import pytest import numpy from numpy import pi -from masque import MinimumStatus, Pather, PortPather, 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 @@ -510,6 +512,45 @@ def test_pather_positional_bound_requires_port_rotation() -> None: p.trace_to('A', None, x=-5) +def test_pather_positional_bound_rejects_non_manhattan_rotation() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(Library(), tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire') + + with pytest.raises(BuildError, match='nearly Manhattan'): + p.trace_to('A', None, p=-5) + + +def test_pather_positional_bound_accepts_nearly_manhattan_rotation() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(Library(), tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=1e-10, ptype='wire') + + p.trace_to('A', None, x=-5) + assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -5e-10), atol=1e-8) + + +def test_pather_bundle_position_bound_rejects_non_manhattan_rotation() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(Library(), tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire') + p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire') + + with pytest.raises(BuildError, match='nearly Manhattan'): + p.trace(['A', 'B'], None, pmin=-5) + + +def test_pather_bundle_extension_bound_allows_non_manhattan_rotation() -> None: + tool = PathTool(layer='M1', width=1, ptype='wire') + p = Pather(Library(), tools=tool, render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire') + p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire') + + p.trace(['A', 'B'], None, emin=5) + assert len(p._paths['A']) == 1 + assert len(p._paths['B']) == 1 + + def test_pather_jog_omitted_length_uses_minimum_length_route() -> None: lib = Library() tool = PathTool(layer='M1', width=1, ptype='wire') diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py index fca27c8..93f874c 100644 --- a/masque/test/test_pather_core.py +++ b/masque/test/test_pather_core.py @@ -327,6 +327,22 @@ def test_selection_management() -> None: assert 'B' not in p.pattern.ports assert pp.ports == [] + +@pytest.mark.parametrize('action', ['plug', 'plugged', 'rename', 'mark', 'fork']) +def test_empty_selection_exact_one_operations_raise_build_error(action: str) -> None: + p = Pather(Library()) + pp = p.at([]) + operations = { + 'plug': lambda: pp.plug('unused', 'A'), + 'plugged': lambda: pp.plugged('A'), + 'rename': lambda: pp.rename('new'), + 'mark': lambda: pp.mark('new'), + 'fork': lambda: pp.fork('new'), + } + + with pytest.raises(BuildError, match='expected exactly one'): + operations[action]() + def test_mark_fork() -> None: lib = Library() p = Pather(lib) diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py index 0ab7ea4..b4ec414 100644 --- a/masque/test/test_pather_primitive_offers.py +++ b/masque/test/test_pather_primitive_offers.py @@ -6,8 +6,9 @@ import numpy import pytest from numpy import pi -from masque import Library, Path, Port, Pather +from masque import Library, Path, Port, Pather, ToolContractError from masque.builder.planner import RoutingPlanner +from masque.builder.planner.planner import Solver, SolverRequest from masque.builder.tools import ( BendOffer, PathTool, @@ -46,6 +47,81 @@ class PlanningOnlyTool(Tool): return tree +def test_tool_contract_error_is_fatal_even_when_an_alternate_offer_exists() -> None: + class BrokenTool(PlanningOnlyTool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + return ( + StraightOffer( + in_ptype='wire', + out_ptype='wire', + endpoint_planner=lambda length: Port((length, 0), pi, ptype='wrong'), + commit_planner=lambda length: length, + ), + StraightOffer.generated('wire', lambda length: length), + ) + + pather = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=BrokenTool(), + render='deferred', + ) + with pytest.raises(ToolContractError, match='declared offer out_ptype'): + pather.straight('A', 5) + + +def test_callback_build_error_remains_recoverable_candidate_rejection() -> None: + class RecoverableTool(PlanningOnlyTool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + + def rejected(length: float) -> Port: + raise BuildError(f'unsupported length {length}') + + return ( + StraightOffer( + in_ptype='wire', + out_ptype='wire', + endpoint_planner=rejected, + commit_planner=lambda length: length, + ), + StraightOffer.generated('wire', lambda length: length), + ) + + pather = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=RecoverableTool(), + render='deferred', + ) + pather.straight('A', 5) + assert numpy.allclose(pather.ports['A'].offset, (-5, 0)) + + +def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None: + class CountingTool(PlanningOnlyTool): + calls = 0 + + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + self.calls += 1 + assert kwargs == {'nested': []} + return () + + tool = CountingTool() + solver = Solver(SolverRequest( + family='straight', + tool=tool, + in_ptype='wire', + tool_options={'nested': []}, + )) + solver.primitive_offers('straight', 'wire') + solver.primitive_offers('straight', 'wire') + assert tool.calls == 1 + + def test_tool_requires_primitive_offers_override() -> None: class RenderOnlyTool(Tool): def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 @@ -100,7 +176,19 @@ def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float) def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None: with pytest.raises(BuildError, match='lower bound must not exceed upper bound'): - canonicalize_offer_parameter(3, (10, 0)) + StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=(10, 0)) + + +@pytest.mark.parametrize('domain', [(numpy.nan, 1), (1, numpy.nan), (numpy.inf, numpy.inf)]) +def test_offer_rejects_invalid_domain_at_construction(domain: tuple[float, float]) -> None: + with pytest.raises(BuildError, match='domain'): + StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) + + +@pytest.mark.parametrize('domain', [(-1, 2), (-numpy.inf, 2)]) +def test_length_offer_requires_finite_nonnegative_minimum(domain: tuple[float, float]) -> None: + with pytest.raises(BuildError, match='finite, nonnegative minimum'): + StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain) def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None: @@ -653,7 +741,7 @@ def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving( p.straight('A', 7) assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7} - assert invalid_parameters == [0.0, 0.0] + assert invalid_parameters == [0.0] def test_pather_commits_only_selected_offer() -> None: diff --git a/masque/test/test_pather_rendering.py b/masque/test/test_pather_rendering.py index 3c50e3b..70360ed 100644 --- a/masque/test/test_pather_rendering.py +++ b/masque/test/test_pather_rendering.py @@ -6,7 +6,7 @@ import numpy from numpy import pi from numpy.testing import assert_allclose -from ..builder import Pather, RouteError +from ..builder import Pather, RouteError, ToolContractError from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool from ..error import BuildError from ..library import Library @@ -307,7 +307,7 @@ def test_pathtool_bend_offer_render_geometry_matches_ports() -> None: offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0] start = Port((0, 0), rotation=pi, ptype="wire") end = offer.endpoint_at(1) - tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),)) + tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(1)),)) pat = tree.top_pattern() path_shape = cast("Path", pat.shapes[(1, 0)][0]) @@ -321,13 +321,13 @@ def test_pathtool_s_offer_render_geometry_matches_ports() -> None: offer = tool.primitive_offers("s", in_ptype="wire")[0] start = Port((0, 0), rotation=pi, ptype="wire") end = offer.endpoint_at(4) - tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),)) + tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(4)),)) pat = tree.top_pattern() path_shape = cast("Path", pat.shapes[(1, 0)][0]) assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10) assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10) - assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10) + assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10) def test_deferred_render_uturn_fallback() -> None: lib = Library() @@ -362,7 +362,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None: tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), pi, ptype='wire'), }) child = Pattern(annotations={'batch': [len(batch)]}) top.ref('_seg') @@ -402,7 +402,7 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None: tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), pi, ptype='wire'), }) child = Pattern(annotations={'length': [length]}) top.ref('_seg') @@ -479,7 +479,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None: tree = Library() top = Pattern(ports={ port_names[0]: Port((0, 0), 0, ptype='wire'), - port_names[1]: Port((length, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), pi, ptype='wire'), }) top.ref('shared') tree['_top'] = top @@ -530,6 +530,52 @@ def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append: assert not p.pattern.refs assert not lib + +def test_pather_render_rejects_opposite_output_rotation() -> None: + class WrongRotationTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + return (StraightOffer.generated('wire', lambda length: length),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data + tree = Library() + tree['_top'] = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), 0, ptype='wire'), + }) + return tree + + p = Pather(Library(), tools=WrongRotationTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + + with pytest.raises(ToolContractError, match='does not match planned endpoint'): + p.render() + + +def test_pather_render_allows_unspecified_output_rotation() -> None: + class UnspecifiedRotationTool(Tool): + def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002 + if kind != 'straight': + return () + return (StraightOffer.generated('wire', lambda length: length),) + + def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 + length = batch[0].data + tree = Library() + tree['_top'] = Pattern(ports={ + port_names[0]: Port((0, 0), 0, ptype='wire'), + port_names[1]: Port((length, 0), None, ptype='wire'), + }) + return tree + + p = Pather(Library(), tools=UnspecifiedRotationTool(), render='deferred') + p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire') + p.straight('A', 10) + p.render() + @pytest.mark.parametrize('append', [True, False]) def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None: class WrongPtypeTool(Tool):