[Pather / Tool] more unification work and fixes

This commit is contained in:
Jan Petykiewicz 2026-07-13 14:44:39 -07:00
commit f9611933ac
19 changed files with 1022 additions and 137 deletions

View file

@ -288,7 +288,25 @@ you are exposing:
common callback behavior. It is not the normal class users should instantiate. common callback behavior. It is not the normal class users should instantiate.
The concrete offer classes carry the semantic fields (`length_domain`, The concrete offer classes carry the semantic fields (`length_domain`,
`jog_domain`, `ccw`) so tools do not need to encode primitive identity in `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: 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 not leak into that namespace, and tool options are not forwarded to
`Tool.render()`. An offer that needs route-specific render behavior must capture `Tool.render()`. An offer that needs route-specific render behavior must capture
the selected value in its `commit()` result (`RenderStep.data`). `AutoTool` the selected value in its `commit()` result (`RenderStep.data`). `AutoTool`
does this automatically for generated straight and S-bend primitives: the flat does this automatically for generated straight and S-bend primitives: the
mapping is snapshotted per selected primitive and passed to its generator as mapping is deep-copied per offer discovery and passed to its generator as
keyword arguments during rendering. AutoTool options must not change generated 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 ports or endpoint geometry. `PathTool` defines no tool options and rejects
nonempty mappings. nonempty mappings.
@ -360,9 +380,51 @@ Primitive offers are local planning objects:
- `parameterized_bbox` may carry opaque future-router footprint metadata - `parameterized_bbox` may carry opaque future-router footprint metadata
- `commit(parameter)` returns opaque render data consumed later by `render()` - `commit(parameter)` returns opaque render data consumed later by `render()`
- `(min, max)` parameter domains are half-open; `(value, value)` is a fixed singleton - `(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` - 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 - `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 Heterogeneous `StraightOffer` and `SOffer` objects may be used as ptype
adapters. Requested `out_ptype` constrains only the final route endpoint; any adapters. Requested `out_ptype` constrains only the final route endpoint; any
intermediate ptypes are chosen by the route solver. 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`, - from `masque`: `RectCollection`, `boolean`, `OverlayLibrary`,
`PortsLibraryView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`, `PortsLibraryView`, `IMaterializable`, `IBorrowing`, `LibraryBuilder`,
`BuildReport`, `CellProvenance`, and `cell` `BuildReport`, `CellProvenance`, and `cell`
- from `masque.builder`: `CostCallable`, the concrete primitive-offer classes, - from `masque.builder`: `CostCallable`, `RenderStepKind`, the concrete
and structured route error/status types primitive-offer classes, structured route error/status types,
`ToolContractCase`, and `validate_tool_contract`
## Minimal migration checklist ## Minimal migration checklist

View file

@ -9,7 +9,9 @@ import numpy
from numpy import pi from numpy import pi
from masque import Pather, Library, Pattern, Port, layer_t from masque import Pather, Library, Pattern, Port, layer_t
from masque.abstract import Abstract 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.error import BuildError
from masque.file.gdsii import writefile from masque.file.gdsii import writefile
from masque.library import ILibrary, SINGLE_USE_PREFIX from masque.library import ILibrary, SINGLE_USE_PREFIX
@ -419,6 +421,18 @@ def prepare_tools() -> tuple[Library, Tool, Tool]:
bend = library.abstract('m2_bend'), bend = library.abstract('m2_bend'),
transitions = via_transitions, 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 return library, M1_tool, M2_tool

View file

@ -84,6 +84,7 @@ from .ports import (
from .abstract import Abstract as Abstract from .abstract import Abstract as Abstract
from .builder import ( from .builder import (
Tool as Tool, Tool as Tool,
ToolContractError as ToolContractError,
Pather as Pather, Pather as Pather,
RouteError as RouteError, RouteError as RouteError,
RouteFailureDetails as RouteFailureDetails, RouteFailureDetails as RouteFailureDetails,

View file

@ -54,6 +54,7 @@ from .pather import (
PortPather as PortPather, PortPather as PortPather,
) )
from .error import ( from .error import (
ToolContractError as ToolContractError,
RouteError as RouteError, RouteError as RouteError,
RouteFailureDetails as RouteFailureDetails, RouteFailureDetails as RouteFailureDetails,
RouteOperation as RouteOperation, RouteOperation as RouteOperation,
@ -61,11 +62,16 @@ from .error import (
MinimumStatus as MinimumStatus, MinimumStatus as MinimumStatus,
) )
from .utils import ell as ell from .utils import ell as ell
from .tool_testing import (
ToolContractCase as ToolContractCase,
validate_tool_contract as validate_tool_contract,
)
from .tools import ( from .tools import (
Tool as Tool, Tool as Tool,
AutoTool as AutoTool, AutoTool as AutoTool,
PathTool as PathTool, PathTool as PathTool,
RenderStep as RenderStep, RenderStep as RenderStep,
RenderStepKind as RenderStepKind,
PrimitiveKind as PrimitiveKind, PrimitiveKind as PrimitiveKind,
CostCallable as CostCallable, CostCallable as CostCallable,
GeneratedEndpointFn as GeneratedEndpointFn, GeneratedEndpointFn as GeneratedEndpointFn,

View file

@ -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

View file

@ -12,6 +12,10 @@ from ..error import BuildError
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn']
class ToolContractError(BuildError):
"""A Tool returned data inconsistent with its routing contract."""
class RouteFailurePolicy(Enum): class RouteFailurePolicy(Enum):
"""Whether route failure may be recovered through alternate/dead planning. """Whether route failure may be recovered through alternate/dead planning.

View file

@ -76,13 +76,14 @@ from .planner.interface import (
RoutePortContext, RoutePortContext,
route_failure_policy, route_failure_policy,
) )
from .error import RouteFailurePolicy from .error import RouteFailurePolicy, ToolContractError
from .planner import ( from .planner import (
RouteTieBreakStrategy, RouteTieBreakStrategy,
RoutingPlanner, RoutingPlanner,
) )
from .planner.bounds import resolved_position_bound from .planner.bounds import resolved_position_bound
from .logging import PatherLogger from .logging import PatherLogger
from ._tolerances import angles_equal, array_close
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -343,7 +344,7 @@ class Pather(PortList):
if not steps: if not steps:
continue continue
port = self.ports.get(name, steps[-1].end_port) 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 return prepared
def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None: def _commit_breaks(self, prepared: Iterable[tuple[str, RenderStep]]) -> None:
@ -1130,7 +1131,7 @@ class Pather(PortList):
return return
tool_name = type(batch[0].tool).__name__ tool_name = type(batch[0].tool).__name__
raise BuildError( raise ToolContractError(
f'Tool {tool_name}.render() returned missing single-use refs for {portspec}: {missing}' 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) actual = pat.ports.get(portspec)
tool_name = type(batch[0].tool).__name__ tool_name = type(batch[0].tool).__name__
if actual is None: if actual is None:
raise BuildError( raise ToolContractError(
f'Tool {tool_name}.render() did not produce output port {portspec!r}; ' f'Tool {tool_name}.render() did not produce output port {portspec!r}; '
f'expected {expected.describe()}' f'expected {expected.describe()}'
) )
offsets_match = bool(numpy.allclose(actual.offset, expected.offset)) offsets_match = array_close(actual.offset, expected.offset)
rotations_match = ( rotations_match = (
actual.rotation is None actual.rotation is None
or expected.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) ptypes_match = ptypes_compatible(actual.ptype, expected.ptype)
if offsets_match and rotations_match and ptypes_match: if offsets_match and rotations_match and ptypes_match:
return return
raise BuildError( raise ToolContractError(
f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: ' f'Tool {tool_name}.render() output port {portspec!r} does not match planned endpoint: '
f'expected {expected.describe()}, got {actual.describe()}' f'expected {expected.describe()}, got {actual.describe()}'
) )
@ -1186,14 +1187,14 @@ class Pather(PortList):
continue continue
batch: list[RenderStep] = [] batch: list[RenderStep] = []
for step in steps: 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 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)): if batch and (not appendable or not same_tool or not batch[-1].is_continuous_with(step)):
render_batch(portspec, batch, append) render_batch(portspec, batch, append)
batch = [] batch = []
if appendable: if appendable:
batch.append(step) 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] del pat.ports[portspec]
if batch: if batch:
render_batch(portspec, batch, append) render_batch(portspec, batch, append)
@ -1285,6 +1286,14 @@ class PortPather:
self.pather = pather self.pather = pather
self.default_spacing = default_spacing 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: def retool(self, tool: Tool) -> Self:
self.pather.retool(tool, self.ports) self.pather.retool(tool, self.ports)
return self return self
@ -1545,28 +1554,24 @@ class PortPather:
strategy: RouteTieBreakStrategy | str | None = None, strategy: RouteTieBreakStrategy | str | None = None,
tool_options: Mapping[str, Any] | None = None, tool_options: Mapping[str, Any] | None = None,
) -> Self: ) -> Self:
if len(self.ports) != 1: port = self._single_port('trace_into')
raise BuildError(f'Unable to use implicit trace_into() with {len(self.ports)} ports; expected exactly one.')
self.pather.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, thru=thru, strategy=strategy, tool_options=tool_options,
) )
return self return self
def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self: def plug(self, other: Abstract | str, other_port: str, **kwargs) -> Self:
if len(self.ports) > 1: port = self._single_port('plug')
raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' self.pather.plug(other, {port: other_port}, **kwargs)
'Use the pather or pattern directly to plug multiple ports.')
self.pather.plug(other, {self.ports[0]: other_port}, **kwargs)
return self return self
def plugged(self, other_port: str | Mapping[str, str]) -> Self: def plugged(self, other_port: str | Mapping[str, str]) -> Self:
if isinstance(other_port, Mapping): if isinstance(other_port, Mapping):
self.pather.plugged(dict(other_port)) 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: else:
self.pather.plugged({self.ports[0]: other_port}) port = self._single_port('plugged')
self.pather.plugged({port: other_port})
return self return self
# #
@ -1604,9 +1609,7 @@ class PortPather:
""" Rename active ports. """ """ Rename active ports. """
name_map: dict[str, str | None] name_map: dict[str, str | None]
if isinstance(name, str): if isinstance(name, str):
if len(self.ports) > 1: name_map = {self._single_port('rename'): name}
raise BuildError('Use a mapping to rename >1 port')
name_map = {self.ports[0]: name}
else: else:
name_map = dict(name) name_map = dict(name)
self.pather.rename_ports(name_map) 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]: def _normalize_copy_map(self, name: str | Mapping[str, str], action: str) -> dict[str, str]:
if isinstance(name, str): if isinstance(name, str):
if len(self.ports) > 1: name_map = {self._single_port(action): name}
raise BuildError(f'Use a mapping to {action} >1 port')
name_map = {self.ports[0]: name}
else: else:
name_map = dict(name) name_map = dict(name)

View file

@ -25,6 +25,7 @@ from numpy.typing import ArrayLike, NDArray
from ...error import BuildError, PortError from ...error import BuildError, PortError
from ...ports import Port from ...ports import Port
from ...utils import rotation_matrix_2d from ...utils import rotation_matrix_2d
from .._tolerances import manhattan_axis
from .interface import RoutePortContext from .interface import RoutePortContext
@ -70,8 +71,13 @@ def resolved_position_bound(
value = finite_scalar(raw_value, f'{key} positional bound') value = finite_scalar(raw_value, f'{key} positional bound')
if port.rotation is None: if port.rotation is None:
raise BuildError('Ports must have rotation') raise BuildError('Ports must have rotation')
is_horiz = bool(numpy.isclose(port.rotation % pi, 0, rtol=1e-9, atol=1e-9)) axis = manhattan_axis(port.rotation)
if is_horiz: 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': if key == 'y':
raise BuildError('Port is horizontal') raise BuildError('Port is horizontal')
target = Port((value, port.offset[1]), rotation=None) target = Port((value, port.offset[1]), rotation=None)

View file

@ -15,7 +15,7 @@ from dataclasses import dataclass
from ...error import BuildError from ...error import BuildError
from ...ports import Port from ...ports import Port
from ..tools import RenderStep, Tool from ..tools import RenderStep, Tool
from ..error import RouteError, RouteFailurePolicy from ..error import RouteError, RouteFailurePolicy, ToolContractError
class RoutePlanningError(BuildError): class RoutePlanningError(BuildError):
@ -34,6 +34,8 @@ class RoutePlanningError(BuildError):
def route_failure_policy(err: Exception) -> RouteFailurePolicy: def route_failure_policy(err: Exception) -> RouteFailurePolicy:
"""Return typed route recovery policy, defaulting generic errors to recoverable.""" """Return typed route recovery policy, defaulting generic errors to recoverable."""
if isinstance(err, ToolContractError):
return RouteFailurePolicy.FATAL
if isinstance(err, RoutePlanningError): if isinstance(err, RoutePlanningError):
return err.policy return err.policy
if isinstance(err, RouteError): if isinstance(err, RouteError):

View file

@ -32,7 +32,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from itertools import combinations from itertools import combinations
from math import cos, isclose as math_isclose, sin from math import cos, sin
from typing import Any, Literal from typing import Any, Literal
import numpy import numpy
@ -50,6 +50,7 @@ from ..tools import (
SOffer, SOffer,
StraightOffer, StraightOffer,
Tool, Tool,
UOffer,
) )
from ..error import ( from ..error import (
MinimumStatus, MinimumStatus,
@ -57,7 +58,9 @@ from ..error import (
RouteFailureDetails, RouteFailureDetails,
RouteFailurePolicy, RouteFailurePolicy,
RouteOperation, RouteOperation,
ToolContractError,
) )
from .._tolerances import scalar_close
from ..utils import ell from ..utils import ell
from . import bounds as planner_bounds from . import bounds as planner_bounds
from .interface import ( from .interface import (
@ -84,7 +87,7 @@ def validate_strategy(strategy: RouteTieBreakStrategy | str) -> RouteTieBreakStr
def is_close(a: float, b: float) -> bool: def is_close(a: float, b: float) -> bool:
"""Compare route-solver scalars with the planner tolerance.""" """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: def clean_parameter(value: float) -> float:
@ -225,8 +228,6 @@ class SelectedPrimitive:
"""Finite additive planning cost reported by the offer.""" """Finite additive planning cost reported by the offer."""
role: Literal['main', 'adapter'] = 'main' role: Literal['main', 'adapter'] = 'main'
"""Whether this step satisfies route geometry or adapts ptype.""" """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) @dataclass(frozen=True, slots=True)
@ -318,7 +319,7 @@ class Solver:
def __init__(self, request: SolverRequest) -> None: def __init__(self, request: SolverRequest) -> None:
self.request = request 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[ self.offer_cache: dict[
tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]], tuple[PrimitiveKind, str | None, str | None, tuple[tuple[str, Any], ...]],
tuple[PrimitiveOffer, ...], tuple[PrimitiveOffer, ...],
@ -333,9 +334,9 @@ class Solver:
for step in steps: for step in steps:
if step.role == 'adapter': if step.role == 'adapter':
continue continue
if step.route_kind == 'bend': if step.offer.kind == 'bend':
count += 1 count += 1
elif step.route_kind in ('s', 'u'): elif step.offer.kind in ('s', 'u'):
count += 2 count += 2
return count return count
@ -371,7 +372,6 @@ class Solver:
tuple(( tuple((
offer_key(step.offer), offer_key(step.offer),
step.role, step.role,
step.route_kind,
round(float(step.parameter), 9), round(float(step.parameter), 9),
endpoint_key(step.out_port), endpoint_key(step.out_port),
) for step in candidate.steps), ) for step in candidate.steps),
@ -448,16 +448,54 @@ class Solver:
kwargs = dict(self.request.tool_options) kwargs = dict(self.request.tool_options)
if extra: if extra:
kwargs.update(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: try:
cache_key = (kind, in_ptype, out_ptype, tuple(sorted(kwargs.items()))) cache_key = (kind, in_ptype, out_ptype, extra_items)
hash(cache_key) hash(cache_key)
except TypeError: 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) cached = self.offer_cache.get(cache_key)
if cached is not None: if cached is not None:
return cached 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 self.offer_cache[cache_key] = offers
return offers return offers
@ -469,7 +507,6 @@ class Solver:
*, *,
out_ptype: str | None, out_ptype: str | None,
role: Literal['main', 'adapter'], role: Literal['main', 'adapter'],
route_kind: PrimitiveKind | None,
route_name: str | None = None, route_name: str | None = None,
) -> SelectedPrimitive: ) -> SelectedPrimitive:
""" """
@ -481,7 +518,7 @@ class Solver:
""" """
route_name = self.request.route_name if route_name is None else route_name route_name = self.request.route_name if route_name is None else route_name
selected = offer.canonicalize_parameter(clean_parameter(parameter)) 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) cached = self.eval_cache.get(key)
if cached is not None: if cached is not None:
return cached return cached
@ -491,28 +528,37 @@ class Solver:
if isinstance(offer, SOffer) and is_close(selected, 0): if isinstance(offer, SOffer) and is_close(selected, 0):
raise BuildError('zero-jog S primitive candidates are not allowed') raise BuildError('zero-jog S primitive candidates are not allowed')
out_port = offer.endpoint_at(selected) 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): 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', 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): if out_ptype is not None and not ptypes_compatible(out_port.ptype, out_ptype):
raise RoutePlanningError( raise RoutePlanningError(
'Requested out_ptype does not match primitive endpoint ptype', 'Requested out_ptype does not match primitive endpoint ptype',
policy=RouteFailurePolicy.FATAL, policy=RouteFailurePolicy.FATAL,
) )
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)) 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): 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: if cost < 0:
raise BuildError(f'{route_name} primitive returned negative cost') raise ToolContractError(f'{route_name} primitive returned negative cost')
primitive = SelectedPrimitive( primitive = SelectedPrimitive(
offer, offer,
selected, selected,
out_port, out_port,
cost, cost,
role=role, role=role,
route_kind=route_kind,
) )
self.eval_cache[key] = primitive self.eval_cache[key] = primitive
return primitive return primitive
@ -532,7 +578,7 @@ class Solver:
for step in steps: for step in steps:
out_port = step.out_port out_port = step.out_port
if out_port.rotation is None: 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_cos = cos(angle)
angle_sin = sin(angle) angle_sin = sin(angle)
x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y) x += angle_cos * float(out_port.x) - angle_sin * float(out_port.y)
@ -573,7 +619,6 @@ class Solver:
current_ptype, current_ptype,
out_ptype=None, out_ptype=None,
role='adapter', role='adapter',
route_kind=kind,
route_name=f'{kind} adapter', route_name=f'{kind} adapter',
) )
except BuildError as err: except BuildError as err:
@ -606,7 +651,6 @@ class Solver:
current_ptype, current_ptype,
out_ptype=None, out_ptype=None,
role='main', role='main',
route_kind='straight',
route_name='trace', route_name='trace',
) )
except BuildError as err: except BuildError as err:
@ -642,7 +686,6 @@ class Solver:
current_ptype, current_ptype,
out_ptype=None, out_ptype=None,
role='main', role='main',
route_kind='bend',
route_name='trace', route_name='trace',
) )
except BuildError as err: except BuildError as err:
@ -678,7 +721,6 @@ class Solver:
current_ptype, current_ptype,
out_ptype=None, out_ptype=None,
role='main', role='main',
route_kind=kind,
route_name=route_name, route_name=route_name,
) )
except BuildError as err: except BuildError as err:
@ -814,7 +856,6 @@ class Solver:
current_ptype, current_ptype,
out_ptype=None, out_ptype=None,
role=step.role, role=step.role,
route_kind=step.route_kind,
) )
selected.append(selected_step) selected.append(selected_step)
current_ptype = selected_step.out_port.ptype current_ptype = selected_step.out_port.ptype
@ -1183,7 +1224,7 @@ class RoutingPlanner:
out_port.rotate_around((0, 0), pi + port_rot) out_port.rotate_around((0, 0), pi + port_rot)
out_port.translate(current.offset) out_port.translate(current.offset)
render_steps.append(RenderStep( render_steps.append(RenderStep(
selected.offer.opcode, selected.offer.kind,
leg.tool, leg.tool,
current.copy(), current.copy(),
out_port.copy(), out_port.copy(),

View file

@ -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,
)

View file

@ -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 cross-ptype routes, and adapter shapes must be exposed by the active Tool as
primitive offers. primitive offers.
""" """
from typing import Literal, Any, Self from typing import Literal, Any, ClassVar, Self
from collections import ChainMap from collections import ChainMap
from collections.abc import Sequence, Callable, Mapping from collections.abc import Sequence, Callable, Mapping
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from copy import deepcopy
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt from math import isclose as scalar_isclose, isfinite as scalar_isfinite, isnan as scalar_isnan, sqrt
from types import MappingProxyType from types import MappingProxyType
@ -68,14 +69,16 @@ from ..abstract import Abstract
from ..library import ILibrary, Library, SINGLE_USE_PREFIX from ..library import ILibrary, Library, SINGLE_USE_PREFIX
from ..error import BuildError from ..error import BuildError
from ..shapes import Path from ..shapes import Path
from ._tolerances import DOMAIN_ATOL, DOMAIN_RTOL, array_close, angles_equal
from .error import ToolContractError
def _canonicalize_domain_value( def _canonicalize_domain_value(
value: float, value: float,
domain: tuple[float, float], domain: tuple[float, float],
*, *,
rtol: float = 1e-9, rtol: float = DOMAIN_RTOL,
atol: float = 1e-12, atol: float = DOMAIN_ATOL,
) -> float: ) -> float:
""" """
Canonicalize a solved primitive parameter against a route domain. Canonicalize a solved primitive parameter against a route domain.
@ -107,6 +110,29 @@ def _canonicalize_domain_value(
return vv 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] EndpointCallable = Callable[[float], Port]
CommitCallable = Callable[[float], Any] CommitCallable = Callable[[float], Any]
BBoxCallable = Callable[[float], NDArray[numpy.float64]] BBoxCallable = Callable[[float], NDArray[numpy.float64]]
@ -114,6 +140,21 @@ DataCallable = Callable[[float], Any]
BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]] BBoxForDataCallable = Callable[[Any], NDArray[numpy.float64]]
CostCallable = Callable[[float, Port], float] CostCallable = Callable[[float, Port], float]
PrimitiveKind = Literal['straight', 'bend', 's', 'u'] 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: 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: def _validated_cost_value(value: Any) -> float:
"""Return a canonical evaluated cost or raise `BuildError`.""" """Return a canonical evaluated cost or raise `ToolContractError`."""
try: try:
cost = float(value) cost = float(value)
except (TypeError, ValueError, OverflowError) as err: 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: 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 return cost
@ -218,6 +259,7 @@ class PrimitiveOffer(ABC):
"""Reserved footprint metadata; the current solver does not inspect it.""" """Reserved footprint metadata; the current solver does not inspect it."""
endpoint_planner: EndpointCallable | None = None endpoint_planner: EndpointCallable | None = None
commit_planner: CommitCallable | None = None commit_planner: CommitCallable | None = None
kind: ClassVar[PrimitiveKind]
def __post_init__(self) -> None: def __post_init__(self) -> None:
has_endpoint = self.endpoint_planner is not None has_endpoint = self.endpoint_planner is not None
@ -225,12 +267,18 @@ class PrimitiveOffer(ABC):
if has_endpoint != has_commit: if has_endpoint != has_commit:
raise BuildError('PrimitiveOffer split callbacks require both endpoint_planner and commit_planner') 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)) object.__setattr__(self, 'cost', _validated_cost(self.cost))
@property @property
@abstractmethod
def opcode(self) -> Literal['L', 'S', 'U']: 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 @property
@abstractmethod @abstractmethod
@ -266,8 +314,12 @@ class PrimitiveOffer(ABC):
out_port = self.endpoint_planner(selected) out_port = self.endpoint_planner(selected)
else: else:
out_port = self.endpoint_at(selected) 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): if callable(self.cost):
value = self.cost(selected, out_port) value = self.cost(parameter, out_port)
else: else:
default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y)) default_cost = abs(float(out_port.x)) + (pi / 2) * abs(float(out_port.y))
value = self.cost * default_cost value = self.cost * default_cost
@ -315,6 +367,7 @@ class PrimitiveOffer(ABC):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class StraightOffer(PrimitiveOffer): class StraightOffer(PrimitiveOffer):
"""Straight or straight-like primitive parameterized by public route length.""" """Straight or straight-like primitive parameterized by public route length."""
kind: ClassVar[Literal['straight']] = 'straight'
length_domain: tuple[float, float] = (0.0, numpy.inf) length_domain: tuple[float, float] = (0.0, numpy.inf)
@classmethod @classmethod
@ -377,10 +430,6 @@ class StraightOffer(PrimitiveOffer):
length_domain = (float(endpoint.x), float(endpoint.x)), length_domain = (float(endpoint.x), float(endpoint.x)),
) )
@property
def opcode(self) -> Literal['L']:
return 'L'
@property @property
def parameter_domain(self) -> tuple[float, float]: def parameter_domain(self) -> tuple[float, float]:
return self.length_domain return self.length_domain
@ -389,6 +438,7 @@ class StraightOffer(PrimitiveOffer):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class BendOffer(PrimitiveOffer): class BendOffer(PrimitiveOffer):
"""Single-turn L-route primitive parameterized by public route length.""" """Single-turn L-route primitive parameterized by public route length."""
kind: ClassVar[Literal['bend']] = 'bend'
ccw: bool = True ccw: bool = True
length_domain: tuple[float, float] = (0.0, numpy.inf) length_domain: tuple[float, float] = (0.0, numpy.inf)
@ -454,10 +504,6 @@ class BendOffer(PrimitiveOffer):
length_domain = (float(endpoint.x), float(endpoint.x)), length_domain = (float(endpoint.x), float(endpoint.x)),
) )
@property
def opcode(self) -> Literal['L']:
return 'L'
@property @property
def parameter_domain(self) -> tuple[float, float]: def parameter_domain(self) -> tuple[float, float]:
return self.length_domain return self.length_domain
@ -466,6 +512,7 @@ class BendOffer(PrimitiveOffer):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class SOffer(PrimitiveOffer): class SOffer(PrimitiveOffer):
"""Non-turning S-route primitive parameterized by jog for a fixed route length.""" """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) jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf)
@classmethod @classmethod
@ -526,10 +573,6 @@ class SOffer(PrimitiveOffer):
jog_domain = (float(endpoint.y), float(endpoint.y)), jog_domain = (float(endpoint.y), float(endpoint.y)),
) )
@property
def opcode(self) -> Literal['S']:
return 'S'
@property @property
def parameter_domain(self) -> tuple[float, float]: def parameter_domain(self) -> tuple[float, float]:
return self.jog_domain return self.jog_domain
@ -538,6 +581,7 @@ class SOffer(PrimitiveOffer):
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class UOffer(PrimitiveOffer): class UOffer(PrimitiveOffer):
"""U-turn-like primitive parameterized by jog for a fixed route length.""" """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) jog_domain: tuple[float, float] = (-numpy.inf, numpy.inf)
@classmethod @classmethod
@ -598,10 +642,6 @@ class UOffer(PrimitiveOffer):
jog_domain = (float(endpoint.y), float(endpoint.y)), jog_domain = (float(endpoint.y), float(endpoint.y)),
) )
@property
def opcode(self) -> Literal['U']:
return 'U'
@property @property
def parameter_domain(self) -> tuple[float, float]: def parameter_domain(self) -> tuple[float, float]:
return self.jog_domain return self.jog_domain
@ -616,16 +656,11 @@ class RenderStep:
passes batches of compatible steps to `Tool.render()` when `Pather.render()` passes batches of compatible steps to `Tool.render()` when `Pather.render()`
is called. is called.
""" """
opcode: Literal['L', 'S', 'U', 'P'] kind: RenderStepKind
""" What operation is being performed. """Canonical primitive kind, or `plug` for an assembly boundary."""
L: straight or single-bend primitive
S: S-like primitive
U: U-like primitive
P: plug
"""
tool: 'Tool | None' 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 start_port: Port
""" Input-side port before this step is rendered. """ """ Input-side port before this step is rendered. """
@ -637,18 +672,28 @@ class RenderStep:
""" Arbitrary tool-specific data""" """ Arbitrary tool-specific data"""
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.opcode != 'P' and self.tool is None: if self.kind not in ('straight', 'bend', 's', 'u', 'plug'):
raise BuildError('Got tool=None but the opcode is not "P"') 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: def is_continuous_with(self, other: 'RenderStep') -> bool:
""" """
Check if another RenderStep can be appended to this one. Check if another RenderStep can be appended to this one.
""" """
# Check continuity with tolerance # 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 ( 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 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 return offsets_match and rotations_match
@ -664,7 +709,7 @@ class RenderStep:
pp.translate(translation) pp.translate(translation)
return RenderStep( return RenderStep(
opcode = self.opcode, kind = self.kind,
tool = self.tool, tool = self.tool,
start_port = new_start, start_port = new_start,
end_port = new_end, end_port = new_end,
@ -682,7 +727,7 @@ class RenderStep:
new_end.flip_across(axis=axis) new_end.flip_across(axis=axis)
return RenderStep( return RenderStep(
opcode = self.opcode, kind = self.kind,
tool = self.tool, tool = self.tool,
start_port = new_start, start_port = new_start,
end_port = new_end, end_port = new_end,
@ -723,6 +768,10 @@ class Tool(ABC):
- `'s'`: a non-turning `SOffer` - `'s'`: a non-turning `SOffer`
- `'u'`: an `UOffer` - `'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 Other `kwargs` come only from the Pather call's explicit
`tool_options` mapping. Tools are responsible for validating the keys `tool_options` mapping. Tools are responsible for validating the keys
they support. they support.
@ -1013,6 +1062,11 @@ class AutoTool(Tool):
two-port straight. Metadata inference does not receive route options. two-port straight. Metadata inference does not receive route options.
""" """
cost = _validated_cost(cost) 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) ptype, in_port_name = self._infer_straight_metadata(fn, length_range, ptype, in_port_name)
def data_at(length: float) -> AutoTool.GeneratedData: def data_at(length: float) -> AutoTool.GeneratedData:
@ -1055,25 +1109,37 @@ class AutoTool(Tool):
in_port = abstract.ports[in_port_name] in_port = abstract.ports[in_port_name]
out_port = abstract.ports[out_port_name] out_port = abstract.ports[out_port_name]
out_ptype = out_port.ptype
source_clockwise = self._bend_clockwise(in_port, out_port) source_clockwise = self._bend_clockwise(in_port, out_port)
if clockwise is not None and bool(clockwise) != source_clockwise: if clockwise is not None and bool(clockwise) != source_clockwise:
raise BuildError('Bend clockwise argument does not match port orientations') raise BuildError('Bend clockwise argument does not match port orientations')
for ccw in (False, True): for ccw in (False, True):
target_clockwise = not bool(ccw) 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_dx = float(bend_dxy[0])
bend_dy = float(bend_dxy[1]) bend_dy = float(bend_dxy[1])
source_matches_target = source_clockwise == target_clockwise
mirrored = mirror and not source_matches_target 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, entry_port_name, mirrored)
reusable_data = self.ReusableData(abstract, port_name, mirrored) endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=exit_port.ptype)
endpoint = Port((bend_dx, bend_dy), rotation=bend_angle, ptype=out_ptype)
self._bend_offers[int(ccw)].append(BendOffer.prebuilt( self._bend_offers[int(ccw)].append(BendOffer.prebuilt(
in_ptype = in_port.ptype, in_ptype = entry_port.ptype,
out_ptype = out_ptype, out_ptype = exit_port.ptype,
endpoint = endpoint, endpoint = endpoint,
data = reusable_data, data = reusable_data,
ccw = ccw, ccw = ccw,
@ -1105,6 +1171,11 @@ class AutoTool(Tool):
Metadata inference and endpoint planning do not receive route options. Metadata inference and endpoint planning do not receive route options.
""" """
cost = _validated_cost(cost) 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( ptype, in_port_name, out_port_name = self._infer_sbend_metadata(
fn, fn,
jog_range, jog_range,
@ -1345,9 +1416,11 @@ class AutoTool(Tool):
@staticmethod @staticmethod
def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]: def _signed_jog_domains(magnitude_range: tuple[float, float]) -> tuple[tuple[float, float], ...]:
lower, upper = (float(magnitude_range[0]), float(magnitude_range[1])) lower, upper = _validated_parameter_domain(
if lower < 0 or lower > upper: magnitude_range,
return () name='S-bend jog_range',
nonnegative_minimum=True,
)
if lower == upper: if lower == upper:
if lower == 0: if lower == 0:
@ -1374,7 +1447,10 @@ class AutoTool(Tool):
) -> tuple[PrimitiveOffer, ...]: ) -> tuple[PrimitiveOffer, ...]:
_ = out_ptype _ = out_ptype
ccw = kwargs.pop('ccw', None) 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: def configured(offer: PrimitiveOffer) -> PrimitiveOffer:
if not tool_options: if not tool_options:
@ -1652,12 +1728,12 @@ class PathTool(Tool):
transform = rotation_matrix_2d(port_rot + pi) transform = rotation_matrix_2d(port_rot + pi)
delta = step.end_port.offset - step.start_port.offset delta = step.end_port.offset - step.start_port.offset
local_end = rotation_matrix_2d(-(port_rot + pi)) @ delta 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])) 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])) local_vertices = self._plan_s_vertices(float(local_end[0]), float(local_end[1]))
else: else:
raise BuildError(f'Unrecognized opcode "{step.opcode}"') raise BuildError(f'Unsupported PathTool render kind {step.kind!r}')
for vertex in local_vertices[1:]: for vertex in local_vertices[1:]:
path_vertices.append(step.start_port.offset + transform @ vertex) 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.path(layer=self.layer, width=self.width, vertices=path_vertices)
pat.ports = { pat.ports = {
port_names[0]: local_batch[0].start_port.copy().rotate(pi), 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 return tree

View file

@ -8,6 +8,7 @@ from numpy.typing import ArrayLike, NDArray
from ..utils import rotation_matrix_2d, SupportsBool from ..utils import rotation_matrix_2d, SupportsBool
from ..error import BuildError from ..error import BuildError
from ._tolerances import manhattan_axis
if TYPE_CHECKING: if TYPE_CHECKING:
from ..ports import Port from ..ports import Port
@ -135,7 +136,13 @@ def ell(
raise BuildError('set_rotation must be finite') raise BuildError('set_rotation must be finite')
rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) 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: 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!') 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: if bound_type in ('xmin', 'xmax') and not is_horizontal:

View file

@ -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. 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 assert bend_count <= max_bends

View file

@ -2,6 +2,7 @@ from contextlib import suppress
from typing import Any from typing import Any
import pytest import pytest
import numpy
from numpy import pi from numpy import pi
from numpy.testing import assert_allclose from numpy.testing import assert_allclose
@ -58,7 +59,7 @@ def rendered_offer_tree(
) -> Library: ) -> Library:
start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk") start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk")
end, data = commit_offer(offer, parameter) 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: 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 = ( tool = (
AutoTool(bbox_library=lib) AutoTool(bbox_library=lib)
.add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8)) .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") .add_transition(lib.abstract("wild_core"), "WILD", "CORE")
) )
return tool, lib 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") 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: def test_autotool_add_bend_rejects_clockwise_mismatch() -> None:
lib = Library() lib = Library()
lib["bend"] = make_bend(2, ptype="wire", clockwise=True) 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) 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: def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
tool = make_sbend_tool((4, 4)) tool = make_sbend_tool((4, 4))
offers = tool.primitive_offers('s', in_ptype="core") 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: def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None:
tool = make_sbend_tool((-4, 4)) with pytest.raises(BuildError, match='finite, nonnegative minimum'):
make_sbend_tool((-4, 4))
assert tool.primitive_offers('s', in_ptype="core") == ()
def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None: 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: def test_autotool_generated_primitives_snapshot_route_options() -> None:
markers: list[str | 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) markers.append(marker)
return make_straight(length, ptype="wire") 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 = Pather(Library(), tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire") 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) p.straight("A", 5, tool_options=first_options)
first_options['marker'] = 'mutated' first_options['marker'] = 'mutated'
first_options['nested']['values'].append(2)
p.straight("A", 6, tool_options={'marker': 'second'}) p.straight("A", 6, tool_options={'marker': 'second'})
first_data, second_data = (step.data for step in p._paths['A']) first_data, second_data = (step.data for step in p._paths['A'])
assert isinstance(first_data, AutoTool.GeneratedData) assert isinstance(first_data, AutoTool.GeneratedData)
assert isinstance(second_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'} assert dict(second_data.tool_options) == {'marker': 'second'}
p.render() p.render()
@ -1131,6 +1177,17 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None:
assert markers == ['first', 'second'] 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( def test_autotool_route_options_do_not_attach_to_reusable_bends(
multi_bend_tool: tuple[AutoTool, Library], multi_bend_tool: tuple[AutoTool, Library],
) -> None: ) -> None:

View file

@ -6,7 +6,9 @@ import pytest
import numpy import numpy
from numpy import pi 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 import RoutePortContext, RoutingPlanner
from masque.builder.planner.planner import NoLegalRouteError from masque.builder.planner.planner import NoLegalRouteError
from masque.builder.tools import BendOffer, PathTool, RenderStep, StraightOffer, Tool, UOffer 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) 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: def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
lib = Library() lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire') tool = PathTool(layer='M1', width=1, ptype='wire')

View file

@ -327,6 +327,22 @@ def test_selection_management() -> None:
assert 'B' not in p.pattern.ports assert 'B' not in p.pattern.ports
assert pp.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: def test_mark_fork() -> None:
lib = Library() lib = Library()
p = Pather(lib) p = Pather(lib)

View file

@ -6,8 +6,9 @@ import numpy
import pytest import pytest
from numpy import pi 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 import RoutingPlanner
from masque.builder.planner.planner import Solver, SolverRequest
from masque.builder.tools import ( from masque.builder.tools import (
BendOffer, BendOffer,
PathTool, PathTool,
@ -46,6 +47,81 @@ class PlanningOnlyTool(Tool):
return tree 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: def test_tool_requires_primitive_offers_override() -> None:
class RenderOnlyTool(Tool): class RenderOnlyTool(Tool):
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002 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: def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'): 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: 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) p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 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: def test_pather_commits_only_selected_offer() -> None:

View file

@ -6,7 +6,7 @@ import numpy
from numpy import pi from numpy import pi
from numpy.testing import assert_allclose 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 ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
from ..error import BuildError from ..error import BuildError
from ..library import Library 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] offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
start = Port((0, 0), rotation=pi, ptype="wire") start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(1) 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() pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0]) 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] offer = tool.primitive_offers("s", in_ptype="wire")[0]
start = Port((0, 0), rotation=pi, ptype="wire") start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(4) 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() pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0]) 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(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"].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: def test_deferred_render_uturn_fallback() -> None:
lib = Library() lib = Library()
@ -362,7 +362,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
tree = Library() tree = Library()
top = Pattern(ports={ top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'), 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)]}) child = Pattern(annotations={'batch': [len(batch)]})
top.ref('_seg') top.ref('_seg')
@ -402,7 +402,7 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
tree = Library() tree = Library()
top = Pattern(ports={ top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'), 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]}) child = Pattern(annotations={'length': [length]})
top.ref('_seg') top.ref('_seg')
@ -479,7 +479,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
tree = Library() tree = Library()
top = Pattern(ports={ top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'), 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') top.ref('shared')
tree['_top'] = top 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 p.pattern.refs
assert not lib 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]) @pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None: def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
class WrongPtypeTool(Tool): class WrongPtypeTool(Tool):