[Pather / Tool] more unification work and fixes

This commit is contained in:
Jan Petykiewicz 2026-07-13 14:44:39 -07:00
commit f9611933ac
19 changed files with 1022 additions and 137 deletions

View file

@ -108,7 +108,7 @@ def assert_route_bend_budget(pather: Pather, portspec: str, max_bends: int) -> N
"""
Assert a simple render-step bend budget for route signatures.
"""
bend_count = sum(1 for step in pather._paths[portspec] if step.opcode == 'L' and step.start_port.rotation != step.end_port.rotation)
bend_count = sum(1 for step in pather._paths[portspec] if step.kind == 'bend')
assert bend_count <= max_bends

View file

@ -2,6 +2,7 @@ from contextlib import suppress
from typing import Any
import pytest
import numpy
from numpy import pi
from numpy.testing import assert_allclose
@ -58,7 +59,7 @@ def rendered_offer_tree(
) -> Library:
start = Port((0, 0), rotation=0, ptype=source_ptype or offer.in_ptype or "unk")
end, data = commit_offer(offer, parameter)
return tool.render((RenderStep(offer.opcode, tool, start, end, data),))
return tool.render((RenderStep(offer.kind, tool, start, end, data),))
def _make_transition_straight(length: float, width: float = 2, ptype: str = "wire") -> Pattern:
@ -375,7 +376,7 @@ def wildcard_transition_tool() -> tuple[AutoTool, Library]:
tool = (
AutoTool(bbox_library=lib)
.add_straight(lambda length: make_straight(length, ptype="core"), "core", "A", length_range=(0, 1e8))
.add_sbend(make_core_sbend, "core", "A", "B", jog_range=(-1e8, 1e8))
.add_sbend(make_core_sbend, "core", "A", "B", jog_range=(0, 1e8))
.add_transition(lib.abstract("wild_core"), "WILD", "CORE")
)
return tool, lib
@ -684,6 +685,24 @@ def test_autotool_add_bend_inferred_names_allow_rotational_reuse_without_mirror(
assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "wire")
def test_autotool_add_bend_reverse_reuse_swaps_cross_ptypes() -> None:
lib = Library()
bend = make_bend(2, ptype="core", clockwise=True)
bend.ports["B"].ptype = "external"
lib["bend"] = bend
tool = AutoTool(bbox_library=lib).add_bend(lib.abstract("bend"), mirror=False)
cw_offer = tool.primitive_offers("bend", in_ptype="core", ccw=False)[0]
ccw_offer = tool.primitive_offers("bend", in_ptype="external", ccw=True)[0]
assert (cw_offer.in_ptype, cw_offer.out_ptype) == ("core", "external")
assert (ccw_offer.in_ptype, ccw_offer.out_ptype) == ("external", "core")
assert cw_offer.commit(2).port_name == "A"
assert ccw_offer.commit(2).port_name == "B"
assert_rendered_offer_endpoint_matches_plan(tool, cw_offer, 2, "core")
assert_rendered_offer_endpoint_matches_plan(tool, ccw_offer, 2, "external")
def test_autotool_add_bend_rejects_clockwise_mismatch() -> None:
lib = Library()
lib["bend"] = make_bend(2, ptype="wire", clockwise=True)
@ -966,6 +985,28 @@ def test_autotool_validates_cost_before_registering_any_offers() -> None:
AutoTool().add_sbend(unused_sbend, jog_range=(-1, 1), cost=-1)
@pytest.mark.parametrize('length_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)])
def test_autotool_rejects_invalid_straight_range_before_sampling(
length_range: tuple[float, float],
) -> None:
def unused_straight(_length: float) -> Pattern:
raise AssertionError('invalid range should be rejected before metadata inference')
with pytest.raises(BuildError, match='Straight length_range'):
AutoTool().add_straight(unused_straight, length_range=length_range)
@pytest.mark.parametrize('jog_range', [(-1, 2), (2, 1), (numpy.nan, 2), (numpy.inf, numpy.inf)])
def test_autotool_rejects_invalid_sbend_range_before_sampling(
jog_range: tuple[float, float],
) -> None:
def unused_sbend(_jog: float) -> Pattern:
raise AssertionError('invalid range should be rejected before metadata inference')
with pytest.raises(BuildError, match='S-bend jog_range'):
AutoTool().add_sbend(unused_sbend, jog_range=jog_range)
def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
tool = make_sbend_tool((4, 4))
offers = tool.primitive_offers('s', in_ptype="core")
@ -982,9 +1023,8 @@ def test_autotool_s_offer_singleton_jog_range_includes_both_signs() -> None:
def test_autotool_s_offer_rejects_negative_minimum_jog_range() -> None:
tool = make_sbend_tool((-4, 4))
assert tool.primitive_offers('s', in_ptype="core") == ()
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
make_sbend_tool((-4, 4))
def test_autotool_uturn_offer_endpoint_matches_rendered_offer() -> None:
@ -1107,7 +1147,12 @@ def test_pather_autotool_uses_l_offer_domains(multi_bend_tool: tuple[AutoTool, L
def test_autotool_generated_primitives_snapshot_route_options() -> None:
markers: list[str | None] = []
def make_marked_straight(length: float, marker: str | None = None) -> Pattern:
def make_marked_straight(
length: float,
marker: str | None = None,
nested: dict[str, list[int]] | None = None,
) -> Pattern:
_ = nested
markers.append(marker)
return make_straight(length, ptype="wire")
@ -1115,15 +1160,16 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None:
p = Pather(Library(), tools=tool, render='deferred')
p.ports["A"] = Port((0, 0), 0, ptype="wire")
first_options = {'marker': 'first'}
first_options = {'marker': 'first', 'nested': {'values': [1]}}
p.straight("A", 5, tool_options=first_options)
first_options['marker'] = 'mutated'
first_options['nested']['values'].append(2)
p.straight("A", 6, tool_options={'marker': 'second'})
first_data, second_data = (step.data for step in p._paths['A'])
assert isinstance(first_data, AutoTool.GeneratedData)
assert isinstance(second_data, AutoTool.GeneratedData)
assert dict(first_data.tool_options) == {'marker': 'first'}
assert dict(first_data.tool_options) == {'marker': 'first', 'nested': {'values': [1]}}
assert dict(second_data.tool_options) == {'marker': 'second'}
p.render()
@ -1131,6 +1177,17 @@ def test_autotool_generated_primitives_snapshot_route_options() -> None:
assert markers == ['first', 'second']
def test_autotool_route_options_must_be_deepcopyable() -> None:
class NotCopyable:
def __deepcopy__(self, memo: dict[int, Any]) -> None:
_ = memo
raise TypeError('no copy')
tool = AutoTool().add_straight(make_straight, 'wire', 'A')
with pytest.raises(BuildError, match='must be deep-copyable'):
tool.primitive_offers('straight', marker=NotCopyable())
def test_autotool_route_options_do_not_attach_to_reusable_bends(
multi_bend_tool: tuple[AutoTool, Library],
) -> None:

View file

@ -6,7 +6,9 @@ import pytest
import numpy
from numpy import pi
from masque import MinimumStatus, Pather, PortPather, Library, Port, RouteError, RouteFailurePolicy
from masque import (
MinimumStatus, Pather, PortPather, 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
@ -510,6 +512,45 @@ def test_pather_positional_bound_requires_port_rotation() -> None:
p.trace_to('A', None, x=-5)
def test_pather_positional_bound_rejects_non_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
with pytest.raises(BuildError, match='nearly Manhattan'):
p.trace_to('A', None, p=-5)
def test_pather_positional_bound_accepts_nearly_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=1e-10, ptype='wire')
p.trace_to('A', None, x=-5)
assert numpy.allclose(p.pattern.ports['A'].offset, (-5, -5e-10), atol=1e-8)
def test_pather_bundle_position_bound_rejects_non_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire')
with pytest.raises(BuildError, match='nearly Manhattan'):
p.trace(['A', 'B'], None, pmin=-5)
def test_pather_bundle_extension_bound_allows_non_manhattan_rotation() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
p = Pather(Library(), tools=tool, render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=pi / 4, ptype='wire')
p.pattern.ports['B'] = Port((1, 1), rotation=pi / 4, ptype='wire')
p.trace(['A', 'B'], None, emin=5)
assert len(p._paths['A']) == 1
assert len(p._paths['B']) == 1
def test_pather_jog_omitted_length_uses_minimum_length_route() -> None:
lib = Library()
tool = PathTool(layer='M1', width=1, ptype='wire')

View file

@ -327,6 +327,22 @@ def test_selection_management() -> None:
assert 'B' not in p.pattern.ports
assert pp.ports == []
@pytest.mark.parametrize('action', ['plug', 'plugged', 'rename', 'mark', 'fork'])
def test_empty_selection_exact_one_operations_raise_build_error(action: str) -> None:
p = Pather(Library())
pp = p.at([])
operations = {
'plug': lambda: pp.plug('unused', 'A'),
'plugged': lambda: pp.plugged('A'),
'rename': lambda: pp.rename('new'),
'mark': lambda: pp.mark('new'),
'fork': lambda: pp.fork('new'),
}
with pytest.raises(BuildError, match='expected exactly one'):
operations[action]()
def test_mark_fork() -> None:
lib = Library()
p = Pather(lib)

View file

@ -6,8 +6,9 @@ import numpy
import pytest
from numpy import pi
from masque import Library, Path, Port, Pather
from masque import Library, Path, Port, Pather, ToolContractError
from masque.builder.planner import RoutingPlanner
from masque.builder.planner.planner import Solver, SolverRequest
from masque.builder.tools import (
BendOffer,
PathTool,
@ -46,6 +47,81 @@ class PlanningOnlyTool(Tool):
return tree
def test_tool_contract_error_is_fatal_even_when_an_alternate_offer_exists() -> None:
class BrokenTool(PlanningOnlyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (
StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wrong'),
commit_planner=lambda length: length,
),
StraightOffer.generated('wire', lambda length: length),
)
pather = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=BrokenTool(),
render='deferred',
)
with pytest.raises(ToolContractError, match='declared offer out_ptype'):
pather.straight('A', 5)
def test_callback_build_error_remains_recoverable_candidate_rejection() -> None:
class RecoverableTool(PlanningOnlyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
def rejected(length: float) -> Port:
raise BuildError(f'unsupported length {length}')
return (
StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=rejected,
commit_planner=lambda length: length,
),
StraightOffer.generated('wire', lambda length: length),
)
pather = Pather(
Library(),
ports={'A': Port((0, 0), rotation=0, ptype='wire')},
tools=RecoverableTool(),
render='deferred',
)
pather.straight('A', 5)
assert numpy.allclose(pather.ports['A'].offset, (-5, 0))
def test_solver_offer_cache_accepts_unhashable_request_tool_options() -> None:
class CountingTool(PlanningOnlyTool):
calls = 0
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
self.calls += 1
assert kwargs == {'nested': []}
return ()
tool = CountingTool()
solver = Solver(SolverRequest(
family='straight',
tool=tool,
in_ptype='wire',
tool_options={'nested': []},
))
solver.primitive_offers('straight', 'wire')
solver.primitive_offers('straight', 'wire')
assert tool.calls == 1
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
@ -100,7 +176,19 @@ def test_offer_canonicalize_parameter_rejects_non_finite_parameter(value: float)
def test_offer_canonicalize_parameter_rejects_reversed_domain() -> None:
with pytest.raises(BuildError, match='lower bound must not exceed upper bound'):
canonicalize_offer_parameter(3, (10, 0))
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=(10, 0))
@pytest.mark.parametrize('domain', [(numpy.nan, 1), (1, numpy.nan), (numpy.inf, numpy.inf)])
def test_offer_rejects_invalid_domain_at_construction(domain: tuple[float, float]) -> None:
with pytest.raises(BuildError, match='domain'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
@pytest.mark.parametrize('domain', [(-1, 2), (-numpy.inf, 2)])
def test_length_offer_requires_finite_nonnegative_minimum(domain: tuple[float, float]) -> None:
with pytest.raises(BuildError, match='finite, nonnegative minimum'):
StraightOffer(in_ptype='wire', out_ptype='wire', length_domain=domain)
def test_ptype_match_distinguishes_exact_wildcard_and_mismatch() -> None:
@ -653,7 +741,7 @@ def test_solver_rejects_rotation_impossible_candidates_before_parameter_solving(
p.straight('A', 7)
assert p._paths['A'][0].data == {'kind': 'valid', 'length': 7}
assert invalid_parameters == [0.0, 0.0]
assert invalid_parameters == [0.0]
def test_pather_commits_only_selected_offer() -> None:

View file

@ -6,7 +6,7 @@ import numpy
from numpy import pi
from numpy.testing import assert_allclose
from ..builder import Pather, RouteError
from ..builder import Pather, RouteError, ToolContractError
from ..builder.tools import PathTool, RenderStep, StraightOffer, Tool
from ..error import BuildError
from ..library import Library
@ -307,7 +307,7 @@ def test_pathtool_bend_offer_render_geometry_matches_ports() -> None:
offer = tool.primitive_offers("bend", in_ptype="wire", ccw=True)[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(1)
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(1)),))
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(1)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
@ -321,13 +321,13 @@ def test_pathtool_s_offer_render_geometry_matches_ports() -> None:
offer = tool.primitive_offers("s", in_ptype="wire")[0]
start = Port((0, 0), rotation=pi, ptype="wire")
end = offer.endpoint_at(4)
tree = tool.render((RenderStep(offer.opcode, tool, start, end, offer.commit(4)),))
tree = tool.render((RenderStep(offer.kind, tool, start, end, offer.commit(4)),))
pat = tree.top_pattern()
path_shape = cast("Path", pat.shapes[(1, 0)][0])
assert_allclose(path_shape.vertices, [[0, 0], [1, 0], [1, 4], [2, 4]], atol=1e-10)
assert_allclose(pat.ports["B"].offset, [2, 4], atol=1e-10)
assert_allclose(pat.ports["B"].rotation, 0, atol=1e-10)
assert_allclose(pat.ports["B"].rotation, pi, atol=1e-10)
def test_deferred_render_uturn_fallback() -> None:
lib = Library()
@ -362,7 +362,7 @@ def test_pather_render_auto_renames_single_use_tool_children() -> None:
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
child = Pattern(annotations={'batch': [len(batch)]})
top.ref('_seg')
@ -402,7 +402,7 @@ def test_custom_tool_render_preserves_segment_subtrees() -> None:
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
child = Pattern(annotations={'length': [length]})
top.ref('_seg')
@ -479,7 +479,7 @@ def test_pather_render_allows_missing_non_single_use_tool_refs() -> None:
tree = Library()
top = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), pi, ptype='wire'),
})
top.ref('shared')
tree['_top'] = top
@ -530,6 +530,52 @@ def test_pather_render_rejects_output_port_that_misses_planned_endpoint(append:
assert not p.pattern.refs
assert not lib
def test_pather_render_rejects_opposite_output_rotation() -> None:
class WrongRotationTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer.generated('wire', lambda length: length),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), 0, ptype='wire'),
})
return tree
p = Pather(Library(), tools=WrongRotationTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
with pytest.raises(ToolContractError, match='does not match planned endpoint'):
p.render()
def test_pather_render_allows_unspecified_output_rotation() -> None:
class UnspecifiedRotationTool(Tool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (StraightOffer.generated('wire', lambda length: length),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs) -> Library: # noqa: ANN001,ANN202,ARG002
length = batch[0].data
tree = Library()
tree['_top'] = Pattern(ports={
port_names[0]: Port((0, 0), 0, ptype='wire'),
port_names[1]: Port((length, 0), None, ptype='wire'),
})
return tree
p = Pather(Library(), tools=UnspecifiedRotationTool(), render='deferred')
p.pattern.ports['A'] = Port((0, 0), rotation=0, ptype='wire')
p.straight('A', 10)
p.render()
@pytest.mark.parametrize('append', [True, False])
def test_pather_render_rejects_output_port_with_wrong_ptype(append: bool) -> None:
class WrongPtypeTool(Tool):