[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, Pather as Pather,
RouteError as RouteError, RouteError as RouteError,
RouteFailureDetails as RouteFailureDetails, RouteFailureDetails as RouteFailureDetails,
RouteFailurePolicy as RouteFailurePolicy,
MinimumStatus as MinimumStatus,
RenderStep as RenderStep, RenderStep as RenderStep,
AutoTool as AutoTool, AutoTool as AutoTool,
PathTool as PathTool, 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. render data after a concrete parameter has been selected.
`Pather` owns user-facing route operations such as `trace()`, `jog()`, `Pather` owns user-facing route operations such as `trace()`, `jog()`,
`uturn()`, and `trace_into()`. For each operation, it asks the active `Tool` for `uturn()`, and `trace_into()`. The internal planner resolves each operation into
offers and passes those offers plus route constraints to the internal router. one or more `SolverRequest`s. This normalization is why the public routing API
The router selects a sequence of internal selected primitives, each pairing an can remain a convenient keyword-based interface without making the solver
offer with a concrete parameter, endpoint, and cost. 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, Selection is pure with respect to caller-owned Pattern, Library, and Pather
`Pather` calls `offer.commit(parameter)` only for the selected primitives and state. Tool offer discovery and endpoint/cost/bbox callbacks must likewise be
stores the returned opaque tool payload in `RenderStep.data`. Later, deterministic and must not mutate that state. `commit()` is the first
`Pather.render()` batches compatible `RenderStep`s and calls `Tool.render()` to selected-offer materialization hook, but it still must not mutate the live
turn those committed payloads into geometry. 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. `PrimitiveOffer` and `RenderStep.data` are the tool-facing contract.
`RenderStep` is `Pather`'s deferred-render record, and `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 - Pather applies the prepared result to ports, deferred render queues, and the
target pattern/library. 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 Code outside the builder package should prefer the exports here over importing
from `masque.builder.planner`. The planner package is intentionally available from `masque.builder.planner`. The planner package is intentionally available
for tests and internal maintenance, but it is not the compatibility boundary for tests and internal maintenance, but it is not the compatibility boundary
@ -45,6 +57,8 @@ from .error import (
RouteError as RouteError, RouteError as RouteError,
RouteFailureDetails as RouteFailureDetails, RouteFailureDetails as RouteFailureDetails,
RouteOperation as RouteOperation, RouteOperation as RouteOperation,
RouteFailurePolicy as RouteFailurePolicy,
MinimumStatus as MinimumStatus,
) )
from .utils import ell as ell from .utils import ell as ell
from .tools import ( from .tools import (

View file

@ -2,6 +2,7 @@
from typing import Any, Literal from typing import Any, Literal
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, auto
from pprint import pformat from pprint import pformat
from types import MappingProxyType from types import MappingProxyType
@ -11,6 +12,33 @@ from ..error import BuildError
RouteOperation = Literal['trace', 'trace_to', 'jog', 'uturn'] 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) @dataclass(frozen=True, slots=True)
class RouteFailureDetails: class RouteFailureDetails:
"""Structured context for a failed Pather routing request.""" """Structured context for a failed Pather routing request."""
@ -23,12 +51,15 @@ class RouteFailureDetails:
resolved_length: float | None resolved_length: float | None
resolved_jog: float | None resolved_jog: float | None
minimum_length: float | None minimum_length: float | None
minimum_attempted: bool minimum_status: MinimumStatus
minimum_exhausted: bool
cause: str cause: str
minimum_cause: str | None = None minimum_cause: str | None = None
def __post_init__(self) -> 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))) object.__setattr__(self, 'request', MappingProxyType(dict(self.request)))
@ -36,16 +67,22 @@ class RouteError(BuildError):
"""A route-selection failure with structured request diagnostics.""" """A route-selection failure with structured request diagnostics."""
details: RouteFailureDetails 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.details = details
self.fatal = fatal self.policy = policy
if not details.minimum_attempted: if details.minimum_status is MinimumStatus.NOT_EVALUATED:
minimum = '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}' 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)' minimum = 'unavailable (no legal route exists at any length)'
else: else:
minimum = 'unavailable (minimum-length calculation failed)' 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 This split keeps selection failures largely transactional for live Pather
state: unsupported primitive combinations can fail before the Pattern, pending state: unsupported primitive combinations can fail before the Pattern, pending
step queue, or Library are touched. Once selected offers are committed and step queue, or Library are touched. Once prepared actions are applied, plug,
prepared actions are applied, later commit, plug, rename, render, or insertion rename, render, or insertion failures may leave partial output; that mutation
failures may leave partial output; that mutation boundary belongs to Pather, boundary belongs to Pather, not to Tool implementations or the route solver.
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 typing import Self, Any, Literal, overload
from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence from collections.abc import Iterator, Iterable, Mapping, MutableMapping, Sequence
@ -55,8 +70,9 @@ from .tools import (
from .planner.interface import ( from .planner.interface import (
PreparedRouteResult, PreparedRouteResult,
RoutePortContext, RoutePortContext,
route_error_is_fatal, route_failure_policy,
) )
from .error import RouteFailurePolicy
from .planner import ( from .planner import (
RouteTieBreakStrategy, RouteTieBreakStrategy,
RoutingPlanner, RoutingPlanner,
@ -116,6 +132,9 @@ class Pather(PortList):
""" """
Tool objects used to dynamically generate new routing segments. Tool objects used to dynamically generate new routing segments.
A key of `None` indicates the default `Tool`. 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 planner: RoutingPlanner
@ -555,7 +574,7 @@ class Pather(PortList):
try: try:
result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, strategy=strategy, **bounds) result = self.planner.plan_trace_route(contexts, ccw, length, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: 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 raise
if length is not None and len(contexts) == 1: if length is not None and len(contexts) == 1:
context = contexts[0] context = contexts[0]
@ -612,7 +631,11 @@ class Pather(PortList):
try: try:
result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, strategy=strategy, **bounds) result = self.planner.plan_trace_to_route(contexts, ccw, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: 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 raise
if bounds.get('length') is not None: if bounds.get('length') is not None:
length = bounds['length'] length = bounds['length']
@ -684,7 +707,11 @@ class Pather(PortList):
try: try:
result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds) result = self.planner.plan_jog_route(contexts, offset, length, spacing=spacing, strategy=strategy, **bounds)
except (BuildError, NotImplementedError) as err: 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 raise
if numpy.isclose(offset, 0): if numpy.isclose(offset, 0):
if length is None: if length is None:
@ -750,7 +777,7 @@ class Pather(PortList):
not self._dead not self._dead
or len(contexts) != 1 or len(contexts) != 1
or length is None or length is None
or route_error_is_fatal(err) or route_failure_policy(err) is RouteFailurePolicy.FATAL
): ):
raise raise
context = contexts[0] context = contexts[0]
@ -830,6 +857,11 @@ class Pather(PortList):
Consecutive compatible `RenderStep`s are batched by port and Tool, then Consecutive compatible `RenderStep`s are batched by port and Tool, then
passed to `Tool.render()`. After insertion, the rendered output port is passed to `Tool.render()`. After insertion, the rendered output port is
checked against the endpoint that planning selected. 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): with self._logger.log_operation(self, 'render', None, append=append):
tool_port_names = ('A', 'B') tool_port_names = ('A', 'B')
@ -902,7 +934,7 @@ class Pather(PortList):
batch: list[RenderStep] = [] batch: list[RenderStep] = []
for step in steps: for step in steps:
appendable = step.opcode in ('L', 'S', 'U') 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)): 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 = []
@ -982,7 +1014,13 @@ class Pather(PortList):
class PortPather: 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__( def __init__(
self, self,
ports: str | Iterable[str], ports: str | Iterable[str],

View file

@ -10,7 +10,7 @@ from .interface import (
PreparedRouteResult as PreparedRouteResult, PreparedRouteResult as PreparedRouteResult,
RoutePlanningError as RoutePlanningError, RoutePlanningError as RoutePlanningError,
RoutePortContext as RoutePortContext, 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 RouteTieBreakStrategy as RouteTieBreakStrategy
from .planner import RoutingPlanner as RoutingPlanner from .planner import RoutingPlanner as RoutingPlanner

View file

@ -15,21 +15,30 @@ 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
class RoutePlanningError(BuildError): class RoutePlanningError(BuildError):
"""Route-planning error with fallback policy metadata.""" """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) super().__init__(*args)
self.fatal = fatal self.policy = policy
def route_error_is_fatal(err: Exception) -> bool: def route_failure_policy(err: Exception) -> RouteFailurePolicy:
"""Return true when a planning error should bypass dead-Pather fallback.""" """Return typed route recovery policy, defaulting generic errors to recoverable."""
return bool(getattr(err, 'fatal', False)) if isinstance(err, RoutePlanningError):
return err.policy
if isinstance(err, RouteError):
return err.policy
return RouteFailurePolicy.RECOVERABLE
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -54,9 +63,10 @@ class PreparedRouteAction:
""" """
Prepared mutation for one routed Pather port. Prepared mutation for one routed Pather port.
The planner has already committed selected primitive offers into Pure selection has already completed, and the planner has materialized the
`render_steps` and computed the final live port. `plug_into`, when set, selected primitive offers into `render_steps` and computed the final live
names the destination port to consume after the route endpoint is applied. port. `plug_into`, when set, names the destination port to consume after the
route endpoint is applied.
""" """
portspec: str portspec: str
"""Live Pather port name to update.""" """Live Pather port name to update."""
@ -73,9 +83,10 @@ class PreparedRouteResult:
""" """
Complete prepared result for one Pather routing operation. Complete prepared result for one Pather routing operation.
`actions` are applied first. `renames` are deferred until after all route `actions` contain materialized, committed render data and are applied first.
actions so trace-into/thru behavior can be represented without exposing the `renames` are deferred until after all route actions so trace-into/thru
solver's selected primitive sequence to Pather. behavior can be represented without exposing the solver's selected
primitive sequence to Pather.
""" """
actions: tuple[PreparedRouteAction, ...] actions: tuple[PreparedRouteAction, ...]
"""Prepared per-port route mutations.""" """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 the planner returns `PreparedRouteResult` records that describe pending
mutations without applying them to the live Pattern. mutations without applying them to the live Pattern.
Public routing modes and bounds are normalized by `bounds.py` before the solver Public routing modes and bounds are normalized by `bounds.py` into per-leg
sees them. This module plans one route intent at a time: it queries Tool offers, `SolverRequest` values. `Solver` performs the pure search: it queries Tool
enumerates bounded primitive compositions, inserts ptype adapters, solves offers, enumerates bounded primitive compositions, inserts ptype adapters,
primitive parameters, ranks candidates, and commits only the selected offers solves primitive parameters, and returns the ranked `Candidate`. A `RouteLeg`
into `RenderStep` payloads. 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 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 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, StraightOffer,
Tool, Tool,
) )
from ..error import RouteError, RouteFailureDetails, RouteOperation from ..error import (
MinimumStatus,
RouteError,
RouteFailureDetails,
RouteFailurePolicy,
RouteOperation,
)
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 (
@ -51,7 +65,7 @@ from .interface import (
PreparedRouteResult, PreparedRouteResult,
RoutePlanningError, RoutePlanningError,
RoutePortContext, RoutePortContext,
route_error_is_fatal, route_failure_policy,
) )
RouteTieBreakStrategy = Literal['straight_first', 'turn_first'] RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
@ -149,7 +163,7 @@ def is_adapter_offer(offer: PrimitiveOffer) -> bool:
def raise_if_fatal(err: Exception) -> None: def raise_if_fatal(err: Exception) -> None:
"""Propagate fatal planning errors while allowing normal candidate rejection.""" """Propagate fatal planning errors while allowing normal candidate rejection."""
if getattr(err, 'fatal', False): if route_failure_policy(err) is RouteFailurePolicy.FATAL:
raise err raise err
@ -231,9 +245,9 @@ class Candidate:
@dataclass(frozen=True, slots=True) @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 Public Pather calls are converted into this smaller shape before grammar
enumeration. `length`, `jog`, and `out_ptype` become endpoint constraints; 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. 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.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, PrimitiveKind | None], SelectedPrimitive] = {}
self.offer_cache: dict[ self.offer_cache: dict[
@ -396,7 +410,7 @@ class Solver:
if not candidates: if not candidates:
for err in errors: for err in errors:
if getattr(err, 'fatal', False): if route_failure_policy(err) is RouteFailurePolicy.FATAL:
raise err raise err
if errors: if errors:
last_error = errors[-1] last_error = errors[-1]
@ -476,12 +490,12 @@ class Solver:
if not ptypes_compatible(out_port.ptype, offer.out_ptype): if not ptypes_compatible(out_port.ptype, offer.out_ptype):
raise RoutePlanningError( raise RoutePlanningError(
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',
fatal=True, 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',
fatal=True, policy=RouteFailurePolicy.FATAL,
) )
cost = float(offer.cost_at(selected)) cost = float(offer.cost_at(selected))
if not numpy.isfinite(cost): if not numpy.isfinite(cost):
@ -993,7 +1007,7 @@ class RoutingPlanner:
bands.append((4, 4)) bands.append((4, 4))
return tuple(bands) return tuple(bands)
def route_request( def solver_request(
self, self,
family: PrimitiveKind, family: PrimitiveKind,
context: RoutePortContext, context: RoutePortContext,
@ -1005,9 +1019,9 @@ class RoutingPlanner:
max_bends: int | None = None, max_bends: int | None = None,
strategy: RouteTieBreakStrategy | str | None = None, strategy: RouteTieBreakStrategy | str | None = None,
**kwargs: Any, **kwargs: Any,
) -> RouteRequest: ) -> SolverRequest:
"""Build normalized solver input for one route leg.""" """Build normalized solver input for one route leg."""
return RouteRequest( return SolverRequest(
family=family, family=family,
tool=context.tool, tool=context.tool,
in_ptype=context.port.ptype, in_ptype=context.port.ptype,
@ -1021,7 +1035,7 @@ class RoutingPlanner:
strategy=self.resolve_strategy(strategy), 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.""" """Construct the solver for a route request."""
return Solver(request) return Solver(request)
@ -1079,13 +1093,12 @@ class RoutingPlanner:
resolved_length=float(length), resolved_length=float(length),
resolved_jog=None if jog is None else float(jog), resolved_jog=None if jog is None else float(jog),
minimum_length=None, minimum_length=None,
minimum_attempted=False, minimum_status=MinimumStatus.NOT_EVALUATED,
minimum_exhausted=False,
cause='Resolved route length must be finite and nonnegative', 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, family=family,
context=context, context=context,
length=length, length=length,
@ -1107,22 +1120,24 @@ class RoutingPlanner:
minimum_length: float | None = None minimum_length: float | None = None
minimum_cause: str | None = None minimum_cause: str | None = None
minimum_exhausted = False minimum_status: MinimumStatus
if length is None: if length is None:
minimum_cause = cause minimum_cause = cause
minimum_exhausted = True minimum_status = MinimumStatus.NO_ROUTE
else: else:
minimum_request = replace(request, length=None) minimum_request = replace(request, length=None)
try: try:
minimum_candidate = self.solver_for_request(minimum_request).solve() minimum_candidate = self.solver_for_request(minimum_request).solve()
minimum_length = minimum_candidate.public_length minimum_length = minimum_candidate.public_length
minimum_status = MinimumStatus.FOUND
except NoLegalRouteError as minimum_err: except NoLegalRouteError as minimum_err:
minimum_cause = str(minimum_err) minimum_cause = str(minimum_err)
minimum_exhausted = True minimum_status = MinimumStatus.NO_ROUTE
except Exception as minimum_err: except Exception as minimum_err:
if route_error_is_fatal(minimum_err): if route_failure_policy(minimum_err) is RouteFailurePolicy.FATAL:
raise raise
minimum_cause = str(minimum_err) minimum_cause = str(minimum_err)
minimum_status = MinimumStatus.FAILED
details = RouteFailureDetails( details = RouteFailureDetails(
operation=operation, operation=operation,
@ -1133,8 +1148,7 @@ class RoutingPlanner:
resolved_length=None if length is None else float(length), resolved_length=None if length is None else float(length),
resolved_jog=None if jog is None else float(jog), resolved_jog=None if jog is None else float(jog),
minimum_length=minimum_length, minimum_length=minimum_length,
minimum_attempted=True, minimum_status=minimum_status,
minimum_exhausted=minimum_exhausted,
cause=cause, cause=cause,
minimum_cause=minimum_cause, minimum_cause=minimum_cause,
) )
@ -1619,7 +1633,7 @@ class RoutingPlanner:
desired.rotation = port_dst.rotation - pi desired.rotation = port_dst.rotation - pi
desired.ptype = out_ptype desired.ptype = out_ptype
family, length, jog, ccw = self.trace_into_spec(context_src.port, desired) family, length, jog, ccw = self.trace_into_spec(context_src.port, desired)
request = self.route_request( request = self.solver_request(
family, family,
context_src, context_src,
length=length, length=length,
@ -1638,7 +1652,7 @@ class RoutingPlanner:
candidate = solver.solve(min_bends=min_bends, max_bends=max_bends) candidate = solver.solve(min_bends=min_bends, max_bends=max_bends)
break break
except (BuildError, NotImplementedError) as err: except (BuildError, NotImplementedError) as err:
if route_error_is_fatal(err): if route_failure_policy(err) is RouteFailurePolicy.FATAL:
raise raise
last_error = err last_error = err
if candidate is None: if candidate is None:

View file

@ -25,11 +25,13 @@ declared `out_ptype`. These are Tool contract requirements rather than
exhaustively runtime-checked properties. exhaustively runtime-checked properties.
Tool authors should treat offer planning callbacks as pure descriptions. The Tool authors should treat offer planning callbacks as pure descriptions. The
solver may call `endpoint_at()`, `cost_at()`, and `bbox_at()` many times while solver may call `endpoint_at()` and `cost_at()` many times while enumerating
enumerating candidate compositions, ptype adapters, and parameter solutions. candidate compositions, ptype adapters, and parameter solutions. Those
Those callbacks should be deterministic and should not mutate a Library, callbacks should be deterministic and should not mutate a Library, Pattern, or
Pattern, or live Pather. `commit()` is the first selected-offer hook: it runs live Pather. `bbox_at()` follows the same purity contract, but footprint data is
only for primitives in the chosen route and returns the opaque value stored in 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`. `RenderStep.data`.
`render()` is the geometry-construction hook. Pather calls it later with a `render()` is the geometry-construction hook. Pather calls it later with a
@ -188,6 +190,7 @@ class PrimitiveOffer(ABC):
priority_bias: float = 0.0 priority_bias: float = 0.0
bbox_planner: BBoxCallable | None = None bbox_planner: BBoxCallable | None = None
parameterized_bbox: Any | None = None parameterized_bbox: Any | None = None
"""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
@ -242,10 +245,11 @@ class PrimitiveOffer(ABC):
def bbox_at(self, parameter: float) -> NDArray[numpy.float64]: 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 Tools may omit this hook by raising `NotImplementedError`; when present
it must return a finite `(2, 2)` min/max array in local coordinates. 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: if self.bbox_planner is None:
raise NotImplementedError raise NotImplementedError
@ -266,9 +270,11 @@ class PrimitiveOffer(ABC):
""" """
Produce opaque render data for a selected primitive. Produce opaque render data for a selected primitive.
`Pather` calls this only for selected primitives while preparing Routing preparation calls this only for selected primitives while
`RenderStep.data`. Unselected candidates are evaluated by endpoint/cost building `RenderStep.data`. Unselected candidates are evaluated by
only and should not need commit-side work. 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) selected = self.canonicalize_parameter(parameter)
if self.commit_planner is not None: 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.testing import assert_equal, assert_allclose
from numpy import pi from numpy import pi
from ..builder import Pather from ..builder import MinimumStatus, Pather, RouteFailureDetails
from ..builder.utils import ell from ..builder.utils import ell
from ..error import BuildError from ..error import BuildError
from ..library import Library from ..library import Library
@ -16,15 +16,48 @@ def test_builder_public_imports() -> None:
from masque import RenderStep as TopRenderStep from masque import RenderStep as TopRenderStep
from masque import RouteError as TopRouteError from masque import RouteError as TopRouteError
from masque import RouteFailureDetails as TopRouteFailureDetails 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 PortPather as BuilderPortPather
from masque.builder import RenderStep as BuilderRenderStep from masque.builder import RenderStep as BuilderRenderStep
from masque.builder import RouteError as BuilderRouteError from masque.builder import RouteError as BuilderRouteError
from masque.builder import RouteFailureDetails as BuilderRouteFailureDetails 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 TopPortPather is BuilderPortPather
assert TopRenderStep is BuilderRenderStep assert TopRenderStep is BuilderRenderStep
assert TopRouteError is BuilderRouteError assert TopRouteError is BuilderRouteError
assert TopRouteFailureDetails is BuilderRouteFailureDetails 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: def test_builder_init() -> None:

View file

@ -5,8 +5,9 @@ import pytest
import numpy import numpy
from numpy import pi 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 import RoutePortContext, RoutingPlanner
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
from masque.error import BuildError from masque.error import BuildError
from masque.library import ILibrary 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 assert details.resolved_jog is None
# The length-2 route exists, but normal cost ranking prefers length 5. # The length-2 route exists, but normal cost ranking prefers length 5.
assert details.minimum_length == 5 assert details.minimum_length == 5
assert details.minimum_attempted assert details.minimum_status is MinimumStatus.FOUND
assert not details.minimum_exhausted assert exc_info.value.policy is RouteFailurePolicy.RECOVERABLE
assert details.minimum_cause is None assert details.minimum_cause is None
assert 'preferred_minimum_length: 5' in str(exc_info.value) assert 'preferred_minimum_length: 5' in str(exc_info.value)
assert tool.commit_calls == 0 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') p.ccw('A', 10, out_ptype='optical')
details = exc_info.value.details 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_length is None
assert details.minimum_exhausted
assert details.minimum_cause is not None assert details.minimum_cause is not None
assert 'no legal route exists at any length' in str(exc_info.value) 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]) @pytest.mark.parametrize('length', [-1, numpy.nan, numpy.inf])
def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: float) -> None: def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length: float) -> None:
tool = RequestCountingTool() tool = RequestCountingTool()
@ -281,7 +322,8 @@ def test_invalid_route_length_fails_before_offer_query_or_dead_fallback(length:
p.ccw('A', length) p.ccw('A', length)
details = exc_info.value.details 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 details.minimum_length is None
assert tool.offer_calls == 0 assert tool.offer_calls == 0
assert numpy.allclose(p.ports['A'].offset, (0, 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 details = exc_info.value.details
assert details.request == {'ccw': True, 'x': 1} assert details.request == {'ccw': True, 'x': 1}
assert details.resolved_length == -1 assert details.resolved_length == -1
assert not details.minimum_attempted assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0 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.portspec == 'A'
assert details.request == {'ccw': True, 'emax': 0, 'spacing': 2} assert details.request == {'ccw': True, 'emax': 0, 'spacing': 2}
assert details.resolved_length == -2 assert details.resolved_length == -2
assert not details.minimum_attempted assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0 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.portspec == 'A'
assert details.request == {'ccw': None, 'each': -2} assert details.request == {'ccw': None, 'each': -2}
assert details.resolved_length == -2 assert details.resolved_length == -2
assert not details.minimum_attempted assert details.minimum_status is MinimumStatus.NOT_EVALUATED
assert tool.offer_calls == 0 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: with pytest.raises(RouteError, match='U-turn') as exc_info:
p.uturn('A', 1.5, length=0) 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 exc_info.value.details.minimum_length is None
assert numpy.allclose(p.pattern.ports['A'].offset, (0, 0)) 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 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: def test_pather_straight(pather_setup: tuple[Pather, PathTool, Library]) -> None:
p, tool, lib = pather_setup p, tool, lib = pather_setup
p.straight("start", 10) 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[(1, 0)]) == 1
assert len(rp.pattern.shapes[(2, 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: def test_portpather_translate_only_affects_future_steps(deferred_render_setup: tuple[Pather, PathTool, Library]) -> None:
rp, tool, lib = deferred_render_setup rp, tool, lib = deferred_render_setup
pp = rp.at("start") pp = rp.at("start")

View file

@ -5,9 +5,9 @@ import pytest
from numpy import pi from numpy import pi
from numpy.testing import assert_equal 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 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.builder.tools import BendOffer, PrimitiveOffer, StraightOffer, Tool
from masque.error import BuildError, PortError from masque.error import BuildError, PortError
@ -280,7 +280,7 @@ class TraceIntoBudgetSolver:
band = (min_bends, max_bends) band = (min_bends, max_bends)
self.attempts.append(band) self.attempts.append(band)
if band in self.fatal_at: if band in self.fatal_at:
raise RoutePlanningError('fatal', fatal=True) raise RoutePlanningError('fatal', policy=RouteFailurePolicy.FATAL)
if band not in self.successes: if band not in self.successes:
raise BuildError('try next budget') raise BuildError('try next budget')
return Candidate((), Port((0, 0), rotation=0, ptype='wire'), 0.0, 0, 0.0) 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 = TraceIntoBudgetSolver(successes, fatal_at=fatal_at)
self.solver_requests = 0 self.solver_requests = 0
def solver_for_request(self, request: RouteRequest) -> Any: def solver_for_request(self, request: SolverRequest) -> Any:
_ = request _ = request
self.solver_requests += 1 self.solver_requests += 1
return self.solver return self.solver