[Pather/Planner] Doc cleanup and more informative error enums
This commit is contained in:
parent
3a3cd854a5
commit
49713896c3
13 changed files with 370 additions and 95 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue