diff --git a/masque/builder/pather.py b/masque/builder/pather.py index b773da8..2968868 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -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. diff --git a/masque/builder/planner/planner.py b/masque/builder/planner/planner.py index 5efcaa1..9fd5a87 100644 --- a/masque/builder/planner/planner.py +++ b/masque/builder/planner/planner.py @@ -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) diff --git a/masque/test/test_autotool_planning.py b/masque/test/test_autotool_planning.py index 2a868fd..33cbee7 100644 --- a/masque/test/test_autotool_planning.py +++ b/masque/test/test_autotool_planning.py @@ -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) diff --git a/masque/test/test_pather_primitive_offers.py b/masque/test/test_pather_primitive_offers.py index b4ec414..626779c 100644 --- a/masque/test/test_pather_primitive_offers.py +++ b/masque/test/test_pather_primitive_offers.py @@ -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