masque/masque/test/test_tool_contract.py

338 lines
12 KiB
Python

from typing import Any
import numpy
import pytest
from numpy import pi
from masque.builder import (
AutoTool,
BendOffer,
PathTool,
PrimitiveKind,
PrimitiveOffer,
RenderStep,
SOffer,
StraightOffer,
Tool,
ToolContractCase,
ToolContractError,
UOffer,
validate_tool_contract,
)
from masque.error import BuildError
from masque.library import ILibrary, Library, SINGLE_USE_PREFIX
from masque.pattern import Pattern
from masque.ports import Port
def make_straight(length: float, *, ptype: str = 'wire') -> Pattern:
return Pattern(ports={
'A': Port((0, 0), 0, ptype=ptype),
'B': Port((length, 0), pi, ptype=ptype),
})
class EmptyTool(Tool):
def primitive_offers(
self,
kind: PrimitiveKind,
*,
in_ptype: str | None = None,
out_ptype: str | None = None,
**kwargs: Any,
) -> tuple[PrimitiveOffer, ...]:
_ = kind, in_ptype, out_ptype, kwargs
return ()
def render(
self,
batch: tuple[RenderStep, ...],
*,
port_names: tuple[str, str] = ('A', 'B'),
) -> ILibrary:
_ = batch
tree, pattern = Library.mktree('empty_tool')
pattern.add_port_pair(names=port_names)
return tree
@pytest.mark.parametrize(
('offer', 'kind', 'opcode'),
[
(StraightOffer('wire', 'wire'), 'straight', 'L'),
(BendOffer('wire', 'wire'), 'bend', 'L'),
(SOffer('wire', 'wire'), 's', 'S'),
(UOffer('wire', 'wire'), 'u', 'U'),
],
)
def test_offer_kind_is_canonical_and_opcode_is_derived(
offer: StraightOffer | BendOffer | SOffer | UOffer,
kind: str,
opcode: str,
) -> None:
assert offer.kind == kind
assert offer.opcode == opcode
def test_render_step_stores_kind_and_derives_opcode() -> None:
tool = PathTool(layer='M1', width=1, ptype='wire')
port = Port((0, 0), 0, ptype='wire')
step = RenderStep('straight', tool, port, port, None)
plug = RenderStep('plug', None, port, port, None)
assert step.kind == 'straight'
assert step.opcode == 'L'
assert step.transformed(numpy.zeros(2), 0, numpy.zeros(2)).kind == 'straight'
assert step.mirrored(0).kind == 'straight'
assert plug.opcode == 'P'
with pytest.raises(BuildError, match='Unrecognized RenderStep kind'):
RenderStep('L', tool, port, port, None) # type: ignore[arg-type]
with pytest.raises(BuildError, match='requires tool=None'):
RenderStep('plug', tool, port, port, None)
def test_standard_offer_endpoint_callback_runs_once_per_solver_evaluation() -> None:
endpoint_calls: list[float] = []
received_endpoints: list[Port] = []
def endpoint(length: float) -> Port:
endpoint_calls.append(length)
return Port((length, 0), pi, ptype='wire')
def cost(length: float, out_port: Port) -> float:
_ = length
received_endpoints.append(out_port)
return out_port.x
class OneOfferTool(EmptyTool):
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',
cost=cost,
endpoint_planner=endpoint,
commit_planner=lambda length: length,
),)
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=OneOfferTool(),
render='deferred',
)
pather.straight('A', 5)
assert endpoint_calls.count(5) == 1
assert len(received_endpoints) >= 1
def test_custom_cost_at_override_remains_authoritative() -> None:
calls: list[float] = []
class CustomCostOffer(StraightOffer):
def cost_at(self, parameter: float) -> float:
calls.append(parameter)
return 0
offer = CustomCostOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length, 0), pi, ptype='wire'),
commit_planner=lambda length: length,
)
class CustomCostTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (offer,) if kind == 'straight' else ()
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=CustomCostTool(),
render='deferred',
)
pather.straight('A', 5)
assert 5 in calls
def test_solver_rejects_offer_kind_mismatch() -> None:
class WrongKindTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
return (SOffer.generated(
'wire',
lambda jog: Port((1, jog), pi, ptype='wire'),
lambda jog: jog,
),)
from masque.builder import Pather
pather = Pather(
Library(),
ports={'A': Port((0, 0), 0, ptype='wire')},
tools=WrongKindTool(),
render='deferred',
)
with pytest.raises(ToolContractError, match='returned.*s.*offer'):
pather.straight('A', 5)
def test_validate_tool_contract_accepts_pathtool() -> None:
tool = PathTool(layer='M1', width=2, ptype='wire')
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
ToolContractCase('bend', in_ptype='wire', ccw=False, check_bbox=True),
ToolContractCase('bend', in_ptype='wire', ccw=True, check_bbox=True),
ToolContractCase('s', in_ptype='wire', check_bbox=True),
ToolContractCase('u', in_ptype='wire', require_offers=False),
))
def test_validate_tool_contract_accepts_autotool_with_explicit_probe() -> None:
tool = AutoTool().add_straight(
make_straight,
'wire',
'A',
length_range=(1, 10),
)
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(7,)),
))
def test_validate_tool_contract_empty_offer_policy() -> None:
tool = EmptyTool()
validate_tool_contract(tool, (ToolContractCase('u', require_offers=False),))
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(tool, (ToolContractCase('u'),))
assert all(isinstance(err, ToolContractError) for err in exc_info.value.exceptions)
assert any('no offers' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_rejects_unmatched_explicit_probe() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
with pytest.raises(ExceptionGroup, match='Tool contract validation') as exc_info:
validate_tool_contract(tool, (
ToolContractCase('straight', in_ptype='wire', probe_parameters=(10,)),
))
assert any('outside every discovered offer domain' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_aggregates_independent_violations() -> None:
class BrokenTool(EmptyTool):
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 + 1, 0), 0, ptype='wrong'),
commit_planner=lambda length: length,
),)
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
return Library()
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(BrokenTool(), (
ToolContractCase('straight', in_ptype='wire', label='broken straight'),
))
errors = exc_info.value.exceptions
assert len(errors) > 1
assert all(isinstance(err, ToolContractError) for err in errors)
assert all('broken straight' in str(err) for err in errors)
def test_validate_tool_contract_detects_repeated_discovery_changes() -> None:
class ChangingTool(EmptyTool):
calls = 0
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
if kind != 'straight':
return ()
self.calls += 1
shift = float(self.calls - 1)
return (StraightOffer(
in_ptype='wire',
out_ptype='wire',
endpoint_planner=lambda length: Port((length + shift, 0), pi, ptype='wire'),
commit_planner=lambda length: length,
),)
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(ChangingTool(), (
ToolContractCase('straight', in_ptype='wire'),
))
assert any('changed after repeated discovery' in str(err) for err in exc_info.value.exceptions)
def test_validate_tool_contract_checks_render_ports_and_single_use_refs() -> None:
class BrokenRenderTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # noqa: ANN001,ANN202,ARG002
tree = Library()
pattern = Pattern(ports={port_names[0]: Port((0, 0), 0, ptype='wire')})
pattern.ref(SINGLE_USE_PREFIX + 'missing')
tree['top'] = pattern
return tree
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(BrokenRenderTool(), (
ToolContractCase('straight', in_ptype='wire'),
))
messages = [str(err) for err in exc_info.value.exceptions]
assert any('missing single-use refs' in message for message in messages)
assert any('missing ports' in message for message in messages)
def test_validate_tool_contract_bbox_is_opt_in() -> None:
tool = AutoTool().add_straight(make_straight, 'wire', 'A', length_range=(1, 5))
validate_tool_contract(tool, (ToolContractCase('straight', in_ptype='wire'),))
# AutoTool supplies bbox support, so use a minimal custom offer without it.
class NoBBoxTool(EmptyTool):
def primitive_offers(self, kind, *, in_ptype=None, out_ptype=None, **kwargs): # noqa: ANN001,ANN202,ARG002
return (StraightOffer.generated('wire', lambda length: length),) if kind == 'straight' else ()
def render(self, batch, *, port_names=('A', 'B'), **kwargs): # 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), pi, ptype='wire'),
})
return tree
validate_tool_contract(NoBBoxTool(), (ToolContractCase('straight', in_ptype='wire'),))
with pytest.raises(ExceptionGroup) as exc_info:
validate_tool_contract(NoBBoxTool(), (
ToolContractCase('straight', in_ptype='wire', check_bbox=True),
))
assert any('bbox_at()' in str(err) for err in exc_info.value.exceptions)
@pytest.mark.parametrize(
'kwargs',
[
{'kind': 'straight', 'ccw': True},
{'kind': 'bend'},
{'kind': 'straight', 'probe_parameters': (numpy.inf,)},
{'kind': 'straight', 'tool_options': {'ccw': True}},
],
)
def test_tool_contract_case_validates_configuration(kwargs: dict[str, Any]) -> None:
with pytest.raises(ValueError, match='ccw|requires|finite|reserved'):
ToolContractCase(**kwargs) # type: ignore[arg-type]