diff --git a/masque/__init__.py b/masque/__init__.py index 6598e2f..defd1ef 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -84,6 +84,8 @@ from .builder import ( Pather as Pather, RouteError as RouteError, RouteFailureDetails as RouteFailureDetails, + RouteFailurePolicy as RouteFailurePolicy, + MinimumStatus as MinimumStatus, RenderStep as RenderStep, AutoTool as AutoTool, PathTool as PathTool, diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 76fd1f8..eba23ba 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -8,16 +8,23 @@ optional footprint metadata, and a commit hook for producing tool-specific render data after a concrete parameter has been selected. `Pather` owns user-facing route operations such as `trace()`, `jog()`, -`uturn()`, and `trace_into()`. For each operation, it asks the active `Tool` for -offers and passes those offers plus route constraints to the internal router. -The router selects a sequence of internal selected primitives, each pairing an -offer with a concrete parameter, endpoint, and cost. +`uturn()`, and `trace_into()`. The internal planner resolves each operation into +one or more `SolverRequest`s. This normalization is why the public routing API +can remain a convenient keyword-based interface without making the solver +stringly typed internally. A pure solver search selects a `Candidate`, and a +`RouteLeg` attaches that candidate to its copied source port and Tool. Only +after selection succeeds are the chosen offers materialized through +`offer.commit(parameter)` into a `PreparedRouteResult` containing +`RenderStep.data`. `Pather` then applies that prepared result to its live ports +and pending render queue. -Route commit is separate from route selection. Once a route is selected, -`Pather` calls `offer.commit(parameter)` only for the selected primitives and -stores the returned opaque tool payload in `RenderStep.data`. Later, -`Pather.render()` batches compatible `RenderStep`s and calls `Tool.render()` to -turn those committed payloads into geometry. +Selection is pure with respect to caller-owned Pattern, Library, and Pather +state. Tool offer discovery and endpoint/cost/bbox callbacks must likewise be +deterministic and must not mutate that state. `commit()` is the first +selected-offer materialization hook, but it still must not mutate the live +layout. `Tool.render()` is the geometry mutation boundary: later, +`Pather.render()` batches compatible `RenderStep`s and inserts the resulting +geometry into the Pattern and Library. `PrimitiveOffer` and `RenderStep.data` are the tool-facing contract. `RenderStep` is `Pather`'s deferred-render record, and @@ -31,6 +38,11 @@ The practical layering is: - Pather applies the prepared result to ports, deferred render queues, and the target pattern/library. +`Pather` intentionally remains a Pattern-oriented facade rather than exposing +separate assembly and routing objects: its user model is a working Pattern with +routing tools attached. The ownership phases above are internal boundaries, +not additional objects callers must coordinate. + Code outside the builder package should prefer the exports here over importing from `masque.builder.planner`. The planner package is intentionally available for tests and internal maintenance, but it is not the compatibility boundary @@ -45,6 +57,8 @@ from .error import ( RouteError as RouteError, RouteFailureDetails as RouteFailureDetails, RouteOperation as RouteOperation, + RouteFailurePolicy as RouteFailurePolicy, + MinimumStatus as MinimumStatus, ) from .utils import ell as ell from .tools import ( diff --git a/masque/builder/error.py b/masque/builder/error.py index e8f00fd..6a647ce 100644 --- a/masque/builder/error.py +++ b/masque/builder/error.py @@ -2,6 +2,7 @@ from typing import Any, Literal from collections.abc import Mapping from dataclasses import dataclass +from enum import Enum, auto from pprint import pformat from types import MappingProxyType @@ -11,6 +12,33 @@ from ..error import BuildError RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] +class RouteFailurePolicy(Enum): + """Whether route failure may be recovered through alternate/dead planning. + + `RECOVERABLE` means a caller-controlled fallback may try another planning + branch. `FATAL` marks an invalid request or broken planning contract that + must be reported directly. + """ + + RECOVERABLE = auto() + FATAL = auto() + + +class MinimumStatus(Enum): + """Outcome of preferred-minimum-length diagnosis. + + `NOT_EVALUATED` is used when diagnosis is inapplicable, notably for an + invalid resolved length. `FOUND` carries `minimum_length`; `NO_ROUTE` means + exhaustive planning found no legal unconstrained route; `FAILED` means the + secondary diagnostic calculation itself raised a recoverable error. + """ + + NOT_EVALUATED = auto() + FOUND = auto() + NO_ROUTE = auto() + FAILED = auto() + + @dataclass(frozen=True, slots=True) class RouteFailureDetails: """Structured context for a failed Pather routing request.""" @@ -23,12 +51,15 @@ class RouteFailureDetails: resolved_length: float | None resolved_jog: float | None minimum_length: float | None - minimum_attempted: bool - minimum_exhausted: bool + minimum_status: MinimumStatus cause: str minimum_cause: str | None = None def __post_init__(self) -> None: + if self.minimum_status is MinimumStatus.FOUND and self.minimum_length is None: + raise BuildError('MinimumStatus.FOUND requires minimum_length') + if self.minimum_status is not MinimumStatus.FOUND and self.minimum_length is not None: + raise BuildError(f'{self.minimum_status} requires minimum_length=None') object.__setattr__(self, 'request', MappingProxyType(dict(self.request))) @@ -36,16 +67,22 @@ class RouteError(BuildError): """A route-selection failure with structured request diagnostics.""" details: RouteFailureDetails - fatal: bool + policy: RouteFailurePolicy - def __init__(self, details: RouteFailureDetails, *, fatal: bool = False) -> None: + def __init__( + self, + details: RouteFailureDetails, + *, + policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE, + ) -> None: self.details = details - self.fatal = fatal - if not details.minimum_attempted: + self.policy = policy + if details.minimum_status is MinimumStatus.NOT_EVALUATED: minimum = 'not evaluated' - elif details.minimum_length is not None: + elif details.minimum_status is MinimumStatus.FOUND: + assert details.minimum_length is not None minimum = f'{details.minimum_length:g}' - elif details.minimum_exhausted: + elif details.minimum_status is MinimumStatus.NO_ROUTE: minimum = 'unavailable (no legal route exists at any length)' else: minimum = 'unavailable (minimum-length calculation failed)' diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 877cd6d..9d6b46b 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -22,10 +22,25 @@ Routing is split into four ownership phases: This split keeps selection failures largely transactional for live Pather state: unsupported primitive combinations can fail before the Pattern, pending -step queue, or Library are touched. Once selected offers are committed and -prepared actions are applied, later commit, plug, rename, render, or insertion -failures may leave partial output; that mutation boundary belongs to Pather, -not to Tool implementations or the route solver. +step queue, or Library are touched. Once prepared actions are applied, plug, +rename, render, or insertion failures may leave partial output; that mutation +boundary belongs to Pather, not to Tool implementations or the route solver. + +Routing policy follows port names. Port-specific Tool assignments and +`PortPather` selections remain attached to their names rather than following a +physical port through arbitrary renames. Pending `RenderStep`s are different: +they snapshot their geometric endpoints when planned. The `_paths` keys are +rendering buckets used for ordering and batching those historical steps, not +identities that can retarget their saved geometry when a name is deleted or +reused. + +While pending steps exist, mutate ports through Pather's methods. Directly +editing `pather.pattern.ports` bypasses the bookkeeping that maintains render +buckets and is unsupported. + +Rendering is not transactional across Pattern and Library mutations. A render +exception is terminal for that Pather: callers may catch it for reporting or +cleanup, but must not retry rendering or continue routing with the same object. """ from typing import Self, Any, Literal, overload from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence @@ -55,8 +70,9 @@ from .tools import ( from .planner.interface import ( PreparedRouteResult, RoutePortContext, - route_error_is_fatal, + route_failure_policy, ) +from .error import RouteFailurePolicy from .planner import ( RouteTieBreakStrategy, RoutingPlanner, @@ -116,6 +132,9 @@ class Pather(PortList): """ Tool objects used to dynamically generate new routing segments. A key of `None` indicates the default `Tool`. + + Non-`None` keys are policies attached to port names. Renaming a port does + not transfer its Tool assignment to the new name. """ planner: RoutingPlanner @@ -555,7 +574,7 @@ class Pather(PortList): try: result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, strategy=strategy, **bounds) except (BuildError, NotImplementedError) as err: - if not self._dead or route_error_is_fatal(err): + if not self._dead or route_failure_policy(err) is RouteFailurePolicy.FATAL: raise if length is not None and len(contexts) == 1: context = contexts[0] @@ -612,7 +631,11 @@ class Pather(PortList): try: result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, strategy=strategy, **bounds) except (BuildError, NotImplementedError) as err: - if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): + if ( + not self._dead + or len(contexts) != 1 + or route_failure_policy(err) is RouteFailurePolicy.FATAL + ): raise if bounds.get('length') is not None: length = bounds['length'] @@ -684,7 +707,11 @@ class Pather(PortList): try: result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds) except (BuildError, NotImplementedError) as err: - if not self._dead or len(contexts) != 1 or route_error_is_fatal(err): + if ( + not self._dead + or len(contexts) != 1 + or route_failure_policy(err) is RouteFailurePolicy.FATAL + ): raise if numpy.isclose(offset, 0): if length is None: @@ -750,7 +777,7 @@ class Pather(PortList): not self._dead or len(contexts) != 1 or length is None - or route_error_is_fatal(err) + or route_failure_policy(err) is RouteFailurePolicy.FATAL ): raise context = contexts[0] @@ -830,6 +857,11 @@ class Pather(PortList): Consecutive compatible `RenderStep`s are batched by port and Tool, then passed to `Tool.render()`. After insertion, the rendered output port is checked against the endpoint that planning selected. + + Rendering may modify the Library before every batch has completed and + does not provide rollback. If this method raises, the Pather must be + treated as unusable; retrying or continuing to route with it is + unsupported. """ with self._logger.log_operation(self, 'render', None, append=append): tool_port_names = ('A', 'B') @@ -902,7 +934,7 @@ class Pather(PortList): batch: list[RenderStep] = [] for step in steps: appendable = step.opcode in ('L', 'S', 'U') - same_tool = batch and step.tool == 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)): render_batch(portspec, batch, append) batch = [] @@ -982,7 +1014,13 @@ class Pather(PortList): class PortPather: - """ Port state manager for fluent pathing. """ + """ + Port-name selection for fluent pathing. + + The selection stores names, not stable physical port identities. Its own + rename/delete helpers update the selection, but unrelated changes made + through the parent Pather do not retarget it. + """ def __init__( self, ports: str | Iterable[str], diff --git a/masque/builder/planner/__init__.py b/masque/builder/planner/__init__.py index f2df9e6..41c53d6 100644 --- a/masque/builder/planner/__init__.py +++ b/masque/builder/planner/__init__.py @@ -10,7 +10,7 @@ from .interface import ( PreparedRouteResult as PreparedRouteResult, RoutePlanningError as RoutePlanningError, RoutePortContext as RoutePortContext, - route_error_is_fatal as route_error_is_fatal, + route_failure_policy as route_failure_policy, ) from .planner import RouteTieBreakStrategy as RouteTieBreakStrategy from .planner import RoutingPlanner as RoutingPlanner diff --git a/masque/builder/planner/interface.py b/masque/builder/planner/interface.py index 58028df..514779a 100644 --- a/masque/builder/planner/interface.py +++ b/masque/builder/planner/interface.py @@ -15,21 +15,30 @@ from dataclasses import dataclass from ...error import BuildError from ...ports import Port from ..tools import RenderStep, Tool +from ..error import RouteError, RouteFailurePolicy class RoutePlanningError(BuildError): """Route-planning error with fallback policy metadata.""" - fatal: bool + policy: RouteFailurePolicy - def __init__(self, *args: object, fatal: bool = False) -> None: + def __init__( + self, + *args: object, + policy: RouteFailurePolicy = RouteFailurePolicy.RECOVERABLE, + ) -> None: super().__init__(*args) - self.fatal = fatal + self.policy = policy -def route_error_is_fatal(err: Exception) -> bool: - """Return true when a planning error should bypass dead-Pather fallback.""" - return bool(getattr(err, 'fatal', False)) +def route_failure_policy(err: Exception) -> RouteFailurePolicy: + """Return typed route recovery policy, defaulting generic errors to recoverable.""" + if isinstance(err, RoutePlanningError): + return err.policy + if isinstance(err, RouteError): + return err.policy + return RouteFailurePolicy.RECOVERABLE @dataclass(frozen=True, slots=True) @@ -54,9 +63,10 @@ class PreparedRouteAction: """ Prepared mutation for one routed Pather port. - The planner has already committed selected primitive offers into - `render_steps` and computed the final live port. `plug_into`, when set, - names the destination port to consume after the route endpoint is applied. + Pure selection has already completed, and the planner has materialized the + selected primitive offers into `render_steps` and computed the final live + port. `plug_into`, when set, names the destination port to consume after the + route endpoint is applied. """ portspec: str """Live Pather port name to update.""" @@ -73,9 +83,10 @@ class PreparedRouteResult: """ Complete prepared result for one Pather routing operation. - `actions` are applied first. `renames` are deferred until after all route - actions so trace-into/thru behavior can be represented without exposing the - solver's selected primitive sequence to Pather. + `actions` contain materialized, committed render data and are applied first. + `renames` are deferred until after all route actions so trace-into/thru + behavior can be represented without exposing the solver's selected + primitive sequence to Pather. """ actions: tuple[PreparedRouteAction, ...] """Prepared per-port route mutations.""" diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 548d429..6e6a3c0 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -6,11 +6,19 @@ Tool primitive offers. `Pather` passes copied `RoutePortContext` snapshots here; the planner returns `PreparedRouteResult` records that describe pending mutations without applying them to the live Pattern. -Public routing modes and bounds are normalized by `bounds.py` before the solver -sees them. This module plans one route intent at a time: it queries Tool offers, -enumerates bounded primitive compositions, inserts ptype adapters, solves -primitive parameters, ranks candidates, and commits only the selected offers -into `RenderStep` payloads. +Public routing modes and bounds are normalized by `bounds.py` into per-leg +`SolverRequest` values. `Solver` performs the pure search: it queries Tool +offers, enumerates bounded primitive compositions, inserts ptype adapters, +solves primitive parameters, and returns the ranked `Candidate`. A `RouteLeg` +then attaches the candidate to its copied source port and Tool. Preparation is +the layer above selection: only chosen offers are committed into `RenderStep` +payloads and returned in a `PreparedRouteResult` for Pather to apply. + +These named stages are architectural boundaries even though most of their +implementation is currently co-located in this module. File layout is an +internal organization choice; the distinction between normalized request, +pure selection, prepared result, and live application is the meaningful +mutation boundary. All search is performed in Tool-local route coordinates. The active input port is at the origin, travel is along +x, and positive jog is to the left. After a @@ -43,7 +51,13 @@ from ..tools import ( StraightOffer, Tool, ) -from ..error import RouteError, RouteFailureDetails, RouteOperation +from ..error import ( + MinimumStatus, + RouteError, + RouteFailureDetails, + RouteFailurePolicy, + RouteOperation, + ) from ..utils import ell from . import bounds as planner_bounds from .interface import ( @@ -51,7 +65,7 @@ from .interface import ( PreparedRouteResult, RoutePlanningError, RoutePortContext, - route_error_is_fatal, + route_failure_policy, ) RouteTieBreakStrategy = Literal['straight_first', 'turn_first'] @@ -149,7 +163,7 @@ def is_adapter_offer(offer: PrimitiveOffer) -> bool: def raise_if_fatal(err: Exception) -> None: """Propagate fatal planning errors while allowing normal candidate rejection.""" - if getattr(err, 'fatal', False): + if route_failure_policy(err) is RouteFailurePolicy.FATAL: raise err @@ -231,9 +245,9 @@ class Candidate: @dataclass(frozen=True, slots=True) -class RouteRequest: +class SolverRequest: """ - Normalized solver input for one route leg. + Normalized input for one `Solver` invocation and one route leg. Public Pather calls are converted into this smaller shape before grammar enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints; @@ -302,7 +316,7 @@ class Solver: enumerated so fixed and adjustable offers share the same path. """ - def __init__(self, request: RouteRequest) -> None: + 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.offer_cache: dict[ @@ -396,7 +410,7 @@ class Solver: if not candidates: for err in errors: - if getattr(err, 'fatal', False): + if route_failure_policy(err) is RouteFailurePolicy.FATAL: raise err if errors: last_error = errors[-1] @@ -476,12 +490,12 @@ class Solver: if not ptypes_compatible(out_port.ptype, offer.out_ptype): raise RoutePlanningError( f'{route_name} primitive endpoint ptype does not match declared offer out_ptype', - fatal=True, + 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', - fatal=True, + policy=RouteFailurePolicy.FATAL, ) cost = float(offer.cost_at(selected)) if not numpy.isfinite(cost): @@ -993,7 +1007,7 @@ class RoutingPlanner: bands.append((4, 4)) return tuple(bands) - def route_request( + def solver_request( self, family: PrimitiveKind, context: RoutePortContext, @@ -1005,9 +1019,9 @@ class RoutingPlanner: max_bends: int | None = None, strategy: RouteTieBreakStrategy | str | None = None, **kwargs: Any, - ) -> RouteRequest: + ) -> SolverRequest: """Build normalized solver input for one route leg.""" - return RouteRequest( + return SolverRequest( family=family, tool=context.tool, in_ptype=context.port.ptype, @@ -1021,7 +1035,7 @@ class RoutingPlanner: strategy=self.resolve_strategy(strategy), ) - def solver_for_request(self, request: RouteRequest) -> Solver: + def solver_for_request(self, request: SolverRequest) -> Solver: """Construct the solver for a route request.""" return Solver(request) @@ -1079,13 +1093,12 @@ class RoutingPlanner: resolved_length=float(length), resolved_jog=None if jog is None else float(jog), minimum_length=None, - minimum_attempted=False, - minimum_exhausted=False, + minimum_status=MinimumStatus.NOT_EVALUATED, cause='Resolved route length must be finite and nonnegative', ) - raise RouteError(details, fatal=True) + raise RouteError(details, policy=RouteFailurePolicy.FATAL) - request = self.route_request( + request = self.solver_request( family=family, context=context, length=length, @@ -1107,22 +1120,24 @@ class RoutingPlanner: minimum_length: float | None = None minimum_cause: str | None = None - minimum_exhausted = False + minimum_status: MinimumStatus if length is None: minimum_cause = cause - minimum_exhausted = True + minimum_status = MinimumStatus.NO_ROUTE else: minimum_request = replace(request, length=None) try: minimum_candidate = self.solver_for_request(minimum_request).solve() minimum_length = minimum_candidate.public_length + minimum_status = MinimumStatus.FOUND except NoLegalRouteError as minimum_err: minimum_cause = str(minimum_err) - minimum_exhausted = True + minimum_status = MinimumStatus.NO_ROUTE except Exception as minimum_err: - if route_error_is_fatal(minimum_err): + if route_failure_policy(minimum_err) is RouteFailurePolicy.FATAL: raise minimum_cause = str(minimum_err) + minimum_status = MinimumStatus.FAILED details = RouteFailureDetails( operation=operation, @@ -1133,8 +1148,7 @@ class RoutingPlanner: resolved_length=None if length is None else float(length), resolved_jog=None if jog is None else float(jog), minimum_length=minimum_length, - minimum_attempted=True, - minimum_exhausted=minimum_exhausted, + minimum_status=minimum_status, cause=cause, minimum_cause=minimum_cause, ) @@ -1619,7 +1633,7 @@ class RoutingPlanner: desired.rotation = port_dst.rotation - pi desired.ptype = out_ptype family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) - request = self.route_request( + request = self.solver_request( family, context_src, length=length, @@ -1638,7 +1652,7 @@ class RoutingPlanner: candidate = solver.solve(min_bends=min_bends, max_bends=max_bends) break except (BuildError, NotImplementedError) as err: - if route_error_is_fatal(err): + if route_failure_policy(err) is RouteFailurePolicy.FATAL: raise last_error = err if candidate is None: diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 702fe7c..9db7645 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -25,11 +25,13 @@ declared `out_ptype`. These are Tool contract requirements rather than exhaustively runtime-checked properties. Tool authors should treat offer planning callbacks as pure descriptions. The -solver may call `endpoint_at()`, `cost_at()`, and `bbox_at()` many times while -enumerating candidate compositions, ptype adapters, and parameter solutions. -Those callbacks should be deterministic and should not mutate a Library, -Pattern, or live Pather. `commit()` is the first selected-offer hook: it runs -only for primitives in the chosen route and returns the opaque value stored in +solver may call `endpoint_at()` and `cost_at()` many times while enumerating +candidate compositions, ptype adapters, and parameter solutions. Those +callbacks should be deterministic and should not mutate a Library, Pattern, or +live Pather. `bbox_at()` follows the same purity contract, but footprint data is +currently reserved for future footprint-aware planning and is not consumed by +the solver. `commit()` is the first selected-offer hook: it runs only for +primitives in the chosen route and returns the opaque value stored in `RenderStep.data`. `render()` is the geometry-construction hook. Pather calls it later with a @@ -188,6 +190,7 @@ class PrimitiveOffer(ABC): priority_bias: float = 0.0 bbox_planner: BBoxCallable | None = None parameterized_bbox: Any | None = None + """Reserved footprint metadata; the current solver does not inspect it.""" endpoint_planner: EndpointCallable | None = None commit_planner: CommitCallable | None = None @@ -242,10 +245,11 @@ class PrimitiveOffer(ABC): def bbox_at(self, parameter: float) -> NDArray[numpy.float64]: """ - Return local primitive bounds for footprint-aware planning. + Return local primitive bounds for future footprint-aware planning. Tools may omit this hook by raising `NotImplementedError`; when present it must return a finite `(2, 2)` min/max array in local coordinates. + The current route solver does not call this method. """ if self.bbox_planner is None: raise NotImplementedError @@ -266,9 +270,11 @@ class PrimitiveOffer(ABC): """ Produce opaque render data for a selected primitive. - `Pather` calls this only for selected primitives while preparing - `RenderStep.data`. Unselected candidates are evaluated by endpoint/cost - only and should not need commit-side work. + Routing preparation calls this only for selected primitives while + building `RenderStep.data`. Unselected candidates are evaluated by + endpoint/cost only and should not need commit-side work. This is a + materialization hook, not the live-layout mutation boundary: it must not + mutate the caller's Pather, Pattern, or Library. """ selected = self.canonicalize_parameter(parameter) if self.commit_planner is not None: diff --git a/masque/test/test_builder.py b/masque/test/test_builder.py index 5251622..4bf5c53 100644 --- a/masque/test/test_builder.py +++ b/masque/test/test_builder.py @@ -3,7 +3,7 @@ import pytest from numpy.testing import assert_equal, assert_allclose from numpy import pi -from ..builder import Pather +from ..builder import MinimumStatus, Pather, RouteFailureDetails from ..builder.utils import ell from ..error import BuildError from ..library import Library @@ -16,15 +16,48 @@ def test_builder_public_imports() -> None: from masque import RenderStep as TopRenderStep from masque import RouteError as TopRouteError from masque import RouteFailureDetails as TopRouteFailureDetails + from masque import RouteFailurePolicy as TopRouteFailurePolicy + from masque import MinimumStatus as TopMinimumStatus from masque.builder import PortPather as BuilderPortPather from masque.builder import RenderStep as BuilderRenderStep from masque.builder import RouteError as BuilderRouteError from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails + from masque.builder import RouteFailurePolicy as BuilderRouteFailurePolicy + from masque.builder import MinimumStatus as BuilderMinimumStatus assert TopPortPather is BuilderPortPather assert TopRenderStep is BuilderRenderStep assert TopRouteError is BuilderRouteError assert TopRouteFailureDetails is BuilderRouteFailureDetails + assert TopRouteFailurePolicy is BuilderRouteFailurePolicy + assert TopMinimumStatus is BuilderMinimumStatus + + +def test_route_failure_details_enforces_minimum_status_invariants() -> None: + common = { + 'operation': 'trace', + 'portspec': 'A', + 'in_ptype': 'wire', + 'out_ptype': 'wide', + 'request': {}, + 'resolved_length': 1, + 'resolved_jog': None, + 'cause': 'no route', + } + + with pytest.raises(BuildError, match='FOUND requires minimum_length'): + RouteFailureDetails( + **common, + minimum_length=None, + minimum_status=MinimumStatus.FOUND, + ) + + with pytest.raises(BuildError, match='requires minimum_length=None'): + RouteFailureDetails( + **common, + minimum_length=2, + minimum_status=MinimumStatus.NO_ROUTE, + ) def test_builder_init() -> None: diff --git a/masque/test/test_pather_constraints.py b/masque/test/test_pather_constraints.py index f695acd..e1c0744 100644 --- a/masque/test/test_pather_constraints.py +++ b/masque/test/test_pather_constraints.py @@ -5,8 +5,9 @@ import pytest import numpy from numpy import pi -from masque import Pather, Library, Port, RouteError +from masque import MinimumStatus, Pather, 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 from masque.error import BuildError from masque.library import ILibrary @@ -216,8 +217,8 @@ def test_route_error_reports_preferred_minimum_and_request_details() -> None: assert details.resolved_jog is None # The length-2 route exists, but normal cost ranking prefers length 5. assert details.minimum_length == 5 - assert details.minimum_attempted - assert not details.minimum_exhausted + assert details.minimum_status is MinimumStatus.FOUND + assert exc_info.value.policy is RouteFailurePolicy.RECOVERABLE assert details.minimum_cause is None assert 'preferred_minimum_length: 5' in str(exc_info.value) assert tool.commit_calls == 0 @@ -260,13 +261,53 @@ def test_route_error_reports_no_route_at_any_length() -> None: p.ccw('A', 10, out_ptype='optical') details = exc_info.value.details - assert details.minimum_attempted + assert details.minimum_status is MinimumStatus.NO_ROUTE assert details.minimum_length is None - assert details.minimum_exhausted assert details.minimum_cause is not None assert 'no legal route exists at any length' in str(exc_info.value) +def test_route_error_reports_failed_minimum_diagnosis(monkeypatch: pytest.MonkeyPatch) -> None: + planner = RoutingPlanner() + context = RoutePortContext( + 'A', + Port((0, 0), rotation=0, ptype='wire'), + PlanningOnlyTool(), + ) + solve_count = 0 + + def solver_for_request(_request: Any) -> Any: + nonlocal solve_count + solve_count += 1 + current_solve = solve_count + + class FailingSolver: + def solve(self) -> Never: + if current_solve == 1: + raise NoLegalRouteError('requested length has no route') + raise RuntimeError('minimum diagnosis failed') + + return FailingSolver() + + monkeypatch.setattr(planner, 'solver_for_request', solver_for_request) + + with pytest.raises(RouteError) as exc_info: + planner.plan_leg( + 'bend', + context, + 'trace_to', + {'ccw': True, 'length': 1}, + length=1, + ccw=True, + ) + + details = exc_info.value.details + assert details.minimum_status is MinimumStatus.FAILED + assert details.minimum_length is None + assert details.minimum_cause == 'minimum diagnosis failed' + assert 'minimum-length calculation failed' in str(exc_info.value) + + @pytest.mark.parametrize('length', [-1, numpy.nan, numpy.inf]) def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: float) -> None: tool = RequestCountingTool() @@ -281,7 +322,8 @@ def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: p.ccw('A', length) details = exc_info.value.details - assert not details.minimum_attempted + assert details.minimum_status is MinimumStatus.NOT_EVALUATED + assert exc_info.value.policy is RouteFailurePolicy.FATAL assert details.minimum_length is None assert tool.offer_calls == 0 assert numpy.allclose(p.ports['A'].offset, (0, 0)) @@ -302,7 +344,7 @@ def test_negative_position_length_reports_bound_without_offer_query() -> None: details = exc_info.value.details assert details.request == {'ccw': True, 'x': 1} assert details.resolved_length == -1 - assert not details.minimum_attempted + assert details.minimum_status is MinimumStatus.NOT_EVALUATED assert tool.offer_calls == 0 @@ -325,7 +367,7 @@ def test_negative_bundle_length_reports_failing_port_and_bound() -> None: assert details.portspec == 'A' assert details.request == {'ccw': True, 'emax': 0, 'spacing': 2} assert details.resolved_length == -2 - assert not details.minimum_attempted + assert details.minimum_status is MinimumStatus.NOT_EVALUATED assert tool.offer_calls == 0 @@ -348,7 +390,7 @@ def test_negative_each_length_reports_failing_port_without_offer_query() -> None assert details.portspec == 'A' assert details.request == {'ccw': None, 'each': -2} assert details.resolved_length == -2 - assert not details.minimum_attempted + assert details.minimum_status is MinimumStatus.NOT_EVALUATED assert tool.offer_calls == 0 @@ -887,7 +929,7 @@ def test_pather_uturn_failed_two_bend_route_is_atomic() -> None: with pytest.raises(RouteError, match='U-turn') as exc_info: p.uturn('A', 1.5, length=0) - assert exc_info.value.details.minimum_attempted + assert exc_info.value.details.minimum_status is MinimumStatus.NO_ROUTE assert exc_info.value.details.minimum_length is None assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) diff --git a/masque/test/test_pather_core.py b/masque/test/test_pather_core.py index cfc426b..fca27c8 100644 --- a/masque/test/test_pather_core.py +++ b/masque/test/test_pather_core.py @@ -93,6 +93,30 @@ def test_pather_accepts_and_reuses_planner_instance() -> None: assert planner.trace_to_calls == 2 +def test_port_tool_policy_and_portpather_selection_follow_names() -> None: + default_tool = PathTool(layer=(1, 0), width=1, ptype='wire') + named_tool = PathTool(layer=(2, 0), width=1, ptype='wire') + p = Pather( + Library(), + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools={None: default_tool, 'A': named_tool}, + render='deferred', + ) + selected = p.at('A') + + p.rename_ports({'A': 'B'}) + + assert selected.ports == ['A'] + assert p.tools['A'] is named_tool + assert 'B' not in p.tools + p.straight('B', 1) + assert p._paths['B'][0].tool is default_tool + + p.mkport('A', Port((10, 0), rotation=0, ptype='wire')) + p.straight('A', 1) + assert p._paths['A'][0].tool is named_tool + + def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None: p, tool, lib = pather_setup p.straight("start", 10) diff --git a/masque/test/test_pather_rendering.py b/masque/test/test_pather_rendering.py index 677fa12..3c50e3b 100644 --- a/masque/test/test_pather_rendering.py +++ b/masque/test/test_pather_rendering.py @@ -101,6 +101,60 @@ def test_deferred_render_retool(deferred_render_setup: tuple[Pather, PathTool, L assert len(rp.pattern.shapes[(1, 0)]) == 1 assert len(rp.pattern.shapes[(2, 0)]) == 1 + +def test_deferred_render_batches_tools_by_identity() -> None: + class CountingTool(PathTool): + def __init__(self, *args, **kwargs) -> None: # noqa: ANN002,ANN003 + super().__init__(*args, **kwargs) + self.render_calls = 0 + + def render(self, *args, **kwargs): # noqa: ANN002,ANN003,ANN202 + self.render_calls += 1 + return super().render(*args, **kwargs) + + lib = Library() + tool1 = CountingTool(layer=(1, 0), width=2, ptype='wire') + tool2 = CountingTool(layer=(1, 0), width=2, ptype='wire') + assert tool1 == tool2 + assert tool1 is not tool2 + p = Pather( + lib, + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=tool1, + render='deferred', + ) + + p.straight('A', 5) + p.retool(tool2, 'A') + p.straight('A', 5) + p.render() + + assert tool1.render_calls == 1 + assert tool2.render_calls == 1 + assert len(p.pattern.shapes[(1, 0)]) == 2 + + +def test_deleted_name_reuse_does_not_retarget_pending_render_steps() -> None: + lib = Library() + tool = PathTool(layer=(1, 0), width=2, ptype='wire') + p = Pather( + lib, + ports={'A': Port((0, 0), rotation=0, ptype='wire')}, + tools=tool, + render='deferred', + ) + + p.straight('A', 5) + original_step = p._paths['A'][0] + p.rename_ports({'A': None}) + p.mkport('A', Port((100, 0), rotation=0, ptype='wire')) + p.straight('A', 5) + + assert len(p._paths['A']) == 2 + assert_allclose(original_step.start_port.offset, (0, 0)) + assert_allclose(original_step.end_port.offset, (-5, 0)) + assert_allclose(p._paths['A'][1].start_port.offset, (100, 0)) + def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None: rp, tool, lib = deferred_render_setup pp = rp.at("start") diff --git a/masque/test/test_pather_trace_into.py b/masque/test/test_pather_trace_into.py index ed967dd..f79ecc1 100644 --- a/masque/test/test_pather_trace_into.py +++ b/masque/test/test_pather_trace_into.py @@ -5,9 +5,9 @@ import pytest from numpy import pi from numpy.testing import assert_equal -from masque import Library, PathTool, Port, Pather +from masque import Library, PathTool, Port, Pather, RouteFailurePolicy from masque.builder.planner import PreparedRouteResult, RoutePlanningError, RoutePortContext, RoutingPlanner -from masque.builder.planner.planner import Candidate, RouteRequest +from masque.builder.planner.planner import Candidate, SolverRequest from masque.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool from masque.error import BuildError, PortError @@ -280,7 +280,7 @@ class TraceIntoBudgetSolver: band = (min_bends, max_bends) self.attempts.append(band) if band in self.fatal_at: - raise RoutePlanningError('fatal', fatal=True) + raise RoutePlanningError('fatal', policy=RouteFailurePolicy.FATAL) if band not in self.successes: raise BuildError('try next budget') return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0) @@ -291,7 +291,7 @@ class TraceIntoBudgetPlanner(RoutingPlanner): self.solver = TraceIntoBudgetSolver(successes, fatal_at=fatal_at) self.solver_requests = 0 - def solver_for_request(self, request: RouteRequest) -> Any: + def solver_for_request(self, request: SolverRequest) -> Any: _ = request self.solver_requests += 1 return self.solver