[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

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