[Pather/Planner] Doc cleanup and more informative error enums

This commit is contained in:
Jan Petykiewicz 2026-07-12 17:25:13 -07:00
commit 49713896c3
13 changed files with 370 additions and 95 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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