[Pather] improve cost calculation for S-first

This commit is contained in:
Jan Petykiewicz 2026-07-16 16:28:56 -07:00
commit 1da5ac550a
4 changed files with 174 additions and 25 deletions

View file

@ -1127,8 +1127,9 @@ class Pather(PortList):
three-bend routes; other families try zero-to-two-bend routes before
four-bend routes. The first band with a legal candidate wins. Within a
band, candidates are ordered by total cost, adapter count, step count,
and deterministic discovery order. `strategy` controls straight-first
vs turn-first discovery order and therefore only the final tie-break.
the requested straight-vs-turn topology preference, and deterministic
discovery order. `strategy` therefore affects only otherwise tied
candidates.
Custom planning options may be supplied through `tool_options`; they
are forwarded only to primitive offer generation.

View file

@ -32,6 +32,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, replace
from itertools import combinations
from math import isclose as math_isclose
from typing import Any, Literal
import numpy
@ -71,6 +72,8 @@ from .interface import (
)
RouteTieBreakStrategy = Literal['straight_first', 'turn_first']
COST_RTOL = 1e-10
COST_ATOL = 1e-8
class NoLegalRouteError(BuildError):
@ -89,6 +92,11 @@ def is_close(a: float, b: float) -> bool:
return scalar_close(a, b)
def costs_equal(a: float, b: float) -> bool:
"""Treat sub-resolution solver noise as equal during cost ranking."""
return math_isclose(float(a), float(b), rel_tol=COST_RTOL, abs_tol=COST_ATOL)
def clean_parameter(value: float) -> float:
"""Snap tiny solver noise in primitive parameters before domain checks."""
rounded = round(float(value))
@ -274,7 +282,7 @@ class SolverRequest:
max_bends: int | None = None
"""Optional override for grammar bend budget."""
strategy: RouteTieBreakStrategy = 'straight_first'
"""Discovery-order tie-break strategy for straight-vs-turn placement."""
"""Final topology tie-break preference for straight-vs-turn placement."""
@property
def route_name(self) -> str:
@ -339,6 +347,15 @@ class Solver:
count += 2
return count
def strategy_rank(self, steps: Sequence[SelectedPrimitive]) -> tuple[int, ...]:
"""Lexicographically rank every main straight-vs-turn placement."""
preferred_kind = 'straight' if self.request.strategy == 'straight_first' else 'turn'
return tuple(
int(('straight' if step.offer.kind == 'straight' else 'turn') != preferred_kind)
for step in steps
if step.role != 'adapter'
)
def candidate_key(self, candidate: Candidate) -> tuple[Any, ...]:
"""Return a deterministic key for duplicate solved candidates."""
def endpoint_key(port: Port) -> tuple[float, float, float | None, str | None]:
@ -425,12 +442,14 @@ class Solver:
) from last_error
raise NoLegalRouteError(f'No legal primitive offer for {self.request.route_name}')
minimum_cost = min(candidate.cost for candidate in candidates)
cost_tied = [candidate for candidate in candidates if costs_equal(candidate.cost, minimum_cost)]
return min(
candidates,
cost_tied,
key=lambda candidate: (
round(float(candidate.cost), 9),
sum(step.role == 'adapter' for step in candidate.steps),
len(candidate.steps),
self.strategy_rank(candidate.steps),
candidate.order,
),
)
@ -793,10 +812,7 @@ class Solver:
residual_jog: float,
) -> Iterable[tuple[SelectedPrimitive, ...]]:
"""Recursively enumerate normal/adapter/turn blocks within the bend budget."""
straight_options = self.straight_options(steps)
if self.request.strategy == 'straight_first':
straight_options = (*straight_options[1:], straight_options[0])
for normal in straight_options:
for normal in self.straight_options(steps):
after_normal = (*steps, *normal)
suffix_options = (
self.adapter_options(after_normal, residual_jog=0)
@ -955,10 +971,10 @@ class Solver:
def finalize(self, steps: Sequence[SelectedPrimitive]) -> Candidate:
"""
Try all small solve sets for one raw sequence and return the first match.
Try all small solve sets for one raw sequence and return the cheapest match.
Solve-set order is deterministic and becomes part of the candidate
ordering only after cost and structural tie-breakers.
Recoverable failures reject only the current solve set. Solve-set order
breaks ties between equally priced parameter allocations.
"""
constraints: list[tuple[Literal['x', 'y'], float]] = []
if self.request.length is not None:
@ -978,24 +994,35 @@ class Solver:
for solve_size in range(1, max_solve + 1):
solve_sets.extend(combinations(adjustable, solve_size))
for solve_indices in solve_sets:
solved = self.solve_parameters(steps, solve_indices, route_constraints)
feasible: list[tuple[float, int, tuple[SelectedPrimitive, ...], Port]] = []
errors: list[Exception] = []
for solve_order, solve_indices in enumerate(solve_sets):
try:
solved = self.solve_parameters(steps, solve_indices, route_constraints)
except (BuildError, NotImplementedError, PortError) as err:
raise_if_fatal(err)
errors.append(err)
continue
if solved is None:
continue
selected_steps, end_port = solved
if not self.endpoint_matches(end_port, route_constraints):
continue
order = self.order
self.order += 1
public_length = float(end_port.x) if self.request.length is None else float(self.request.length)
return Candidate(
tuple(selected_steps),
end_port,
sum(step.cost for step in selected_steps),
order,
public_length,
)
raise BuildError(f'{self.request.route_name} composed primitive route is unsupported')
cost = sum(step.cost for step in selected_steps)
feasible.append((float(cost), solve_order, tuple(selected_steps), end_port))
if not feasible:
if errors:
raise errors[-1]
raise BuildError(f'{self.request.route_name} composed primitive route is unsupported')
minimum_cost = min(result[0] for result in feasible)
cost_tied = [result for result in feasible if costs_equal(result[0], minimum_cost)]
cost, _solve_order, selected_steps, end_port = min(cost_tied, key=lambda result: result[1])
order = self.order
self.order += 1
public_length = float(end_port.x) if self.request.length is None else float(self.request.length)
return Candidate(selected_steps, end_port, cost, order, public_length)
@dataclass(frozen=True, slots=True)

View file

@ -950,6 +950,59 @@ def test_autotool_sbend_explicit_cost_can_override_geometric_cost() -> None:
assert_allclose(out_port.offset, [20, 4])
@pytest.mark.parametrize('jog', [41_988.0, -41_988.0])
def test_autotool_strategy_orders_main_steps_across_adapters(jog: float) -> None:
radius = 60_000.0
transition_length = 50_000.0
route_length = 545_929.0
primary = 'primary'
secondary = 'secondary'
sbend_endpoint = circular_arc_sbend_endpoint(radius, primary)
def make_primary_sbend(offset: float) -> Pattern:
pattern = Pattern()
pattern.ports['A'] = Port((0, 0), 0, ptype=primary)
pattern.ports['B'] = sbend_endpoint(offset)
return pattern
library = Library()
library['bend'] = make_bend(radius, ptype=primary)
transition = Pattern()
transition.ports['P'] = Port((0, 0), 0, ptype=primary)
transition.ports['S'] = Port((transition_length, 0), pi, ptype=secondary)
library['transition'] = transition
tool = (
AutoTool(bbox_library=library)
.add_straight(lambda length: make_straight(length, ptype=primary), primary, 'A')
.add_straight(
lambda length: make_straight(length, ptype=secondary),
secondary,
'A',
cost=0.3,
)
.add_bend(library.abstract('bend'), 'A', 'B', clockwise=True)
.add_sbend(
make_primary_sbend,
primary,
'A',
'B',
jog_range=(0, 2 * radius),
endpoint=sbend_endpoint,
)
.add_transition(library.abstract('transition'), 'P', 'S')
)
selected_kinds = {}
for strategy in ('straight_first', 'turn_first'):
pather = Pather(library, tools=tool, render='deferred')
pather.ports['A'] = Port((0, 0), 0, ptype=primary)
pather.jog('A', jog, length=route_length, out_ptype=primary, strategy=strategy)
selected_kinds[strategy] = [step.kind for step in pather._paths['A']]
assert selected_kinds['straight_first'] == ['straight', 'straight', 'straight', 's']
assert selected_kinds['turn_first'] == ['s', 'straight', 'straight', 'straight']
def test_autotool_add_methods_propagate_callable_cost_to_all_created_offers() -> None:
def cost(parameter: float, endpoint: Port) -> float:
return abs(parameter) + abs(endpoint.x) + abs(endpoint.y)

View file

@ -122,6 +122,74 @@ def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None:
assert tool.calls == 1
def test_solver_finalize_chooses_cheapest_parameter_allocation() -> None:
tool = PlanningOnlyTool()
solver = Solver(SolverRequest(
family='straight',
tool=tool,
in_ptype='wire',
tool_options={},
length=10,
))
expensive_offer = StraightOffer.generated('wire', lambda length: length, cost=1)
cheap_offer = StraightOffer.generated('wire', lambda length: length, cost=0.3)
expensive = solver.evaluate(expensive_offer, 0, 'wire', out_ptype=None, role='main')
cheap = solver.evaluate(cheap_offer, 0, 'wire', out_ptype=None, role='main')
for steps in ((expensive, cheap), (cheap, expensive)):
candidate = solver.finalize(steps)
parameters = {step.offer: step.parameter for step in candidate.steps}
assert parameters[cheap_offer] == pytest.approx(10)
assert parameters[expensive_offer] == pytest.approx(0)
assert candidate.cost == pytest.approx(3)
def test_solver_strategy_rank_covers_all_main_steps_and_ignores_adapters() -> None:
tool = PlanningOnlyTool()
straight_first = Solver(SolverRequest(
family='s',
tool=tool,
in_ptype='wire',
tool_options={},
strategy='straight_first',
))
turn_first = Solver(SolverRequest(
family='s',
tool=tool,
in_ptype='wire',
tool_options={},
strategy='turn_first',
))
straight_offer = StraightOffer.generated('wire', lambda length: length)
bend_offer = BendOffer.prebuilt(
'wire',
'wire',
Port((1, 1), 3 * pi / 2, ptype='wire'),
None,
ccw=True,
)
adapter_offer = StraightOffer.prebuilt(
'wire',
'adapted',
Port((1, 0), pi, ptype='adapted'),
None,
)
straight = straight_first.evaluate(straight_offer, 0, 'wire', out_ptype=None, role='main')
turn = straight_first.evaluate(bend_offer, 1, 'wire', out_ptype=None, role='main')
adapter = straight_first.evaluate(adapter_offer, 1, 'wire', out_ptype=None, role='adapter')
alternating = (straight, turn, adapter, straight, turn)
delayed_straight = (straight, turn, adapter, turn, straight)
assert straight_first.strategy_rank(alternating) < straight_first.strategy_rank(delayed_straight)
assert turn_first.strategy_rank(delayed_straight) < turn_first.strategy_rank(alternating)
assert straight_first.strategy_rank(alternating) == straight_first.strategy_rank(
(straight, turn, straight, turn),
)
assert straight_first.strategy_rank((straight,)) < straight_first.strategy_rank((turn,))
assert turn_first.strategy_rank((turn,)) < turn_first.strategy_rank((straight,))
def test_tool_requires_primitive_offers_override() -> None:
class RenderOnlyTool(Tool):
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002