From 1cadf1ae839a97b6063a2819ef785b1270143971 Mon Sep 17 00:00:00 2001 From: Matthieu Monsch <1216372+mtth@users.noreply.github.com> Date: Tue, 30 May 2023 15:08:22 -0700 Subject: [PATCH] Support space multiplication (#74) --- opvious/client/handlers.py | 4 +- opvious/modeling/__init__.py | 2 + opvious/modeling/ast.py | 122 +++++++++++++++++++++++++------- opvious/modeling/definitions.py | 25 ++++--- opvious/modeling/identifiers.py | 2 +- opvious/modeling/quantified.py | 4 +- pyproject.toml | 2 +- tests/test_executors.py | 10 ++- tests/test_modeling.py | 19 +++-- 9 files changed, 134 insertions(+), 56 deletions(-) diff --git a/opvious/client/handlers.py b/opvious/client/handlers.py index 7f64360..cee8bf8 100644 --- a/opvious/client/handlers.py +++ b/opvious/client/handlers.py @@ -271,7 +271,7 @@ async def inspect_solve_instructions( The LP formatted output will be fully annotated with matching keys and labels: - .. code:: + .. code-block:: minimize +1 inventory$1 \\ [day=0] @@ -348,7 +348,7 @@ async def run_solve( The returned response exposes both metadata (status, objective value, etc.) and solution data (if the solve was feasible): - .. code:: python + .. code-block:: python response = await client.run_solve( specification=opvious.RemoteSpecification.example( diff --git a/opvious/modeling/__init__.py b/opvious/modeling/__init__.py index 92a9776..7c75526 100644 --- a/opvious/modeling/__init__.py +++ b/opvious/modeling/__init__.py @@ -6,6 +6,7 @@ Domain, Expression, ExpressionLike, + IterableSpace, Predicate, Projection, Quantifiable, @@ -65,6 +66,7 @@ "total", # Quantification "Cross", + "IterableSpace", "Domain", "Projection", "Quantifiable", diff --git a/opvious/modeling/ast.py b/opvious/modeling/ast.py index df3f2e0..9d7b047 100644 --- a/opvious/modeling/ast.py +++ b/opvious/modeling/ast.py @@ -4,7 +4,18 @@ import dataclasses import itertools import math -from typing import Any, cast, Iterable, Optional, Sequence, TypeVar, Union +from typing import ( + Any, + cast, + Iterable, + Iterator, + Mapping, + Optional, + Protocol, + Sequence, + TypeVar, + Union, +) from ..common import untuple from .identifiers import ( @@ -261,7 +272,7 @@ def render(self) -> str: groups = [] outer = itertools.groupby(self.quantifiers, _quantifier_grouping_key) for (_id, key), outer_qs in outer: - if isinstance(key, Space): + if isinstance(key, ScalarSpace): names = ", ".join(q.format() for q in outer_qs) groups.append(f"{names} \\in {key.render()}") else: @@ -285,10 +296,10 @@ def render(self) -> str: def _quantifier_grouping_key( q: QuantifierIdentifier, -) -> tuple[int, Union[Space, QuantifierGroup]]: +) -> tuple[int, Union[ScalarSpace, QuantifierGroup]]: # We add the ID to prevent `__eq__` from being called on equations sp = q.space - if not isinstance(sp, Space): + if not isinstance(sp, ScalarSpace): raise TypeError(f"Unexpected space: {sp}") if not q.groups: return (id(sp), sp) @@ -318,7 +329,7 @@ def render(self, _precedence=0) -> str: with local_formatting_scope(qs): if len(qs) == 1 and self.domain.mask is None: _id, key = _quantifier_grouping_key(qs[0]) - if isinstance(key, Space): + if isinstance(key, ScalarSpace): sp = key.render() else: sp = render_identifier(key.alias, *key.subscripts) @@ -349,6 +360,25 @@ def render(self, precedence=0) -> str: class Space: + """Base quantification + + + This class provides support for generating cross-products with the `*` + operator (see :func:`~opvious.modeling.cross`): + + .. code-block:: python + + space1 * space2 # Equivalent to cross(space1, space2) + """ + + def __mul__(self, other: Quantifiable) -> Quantification: + return cross(self, other) + + def __rmul__(self, left: Quantifiable) -> Quantification: + return cross(left, self) + + +class ScalarSpace(Space): def __iter__(self) -> Quantified[Quantifier]: return (untuple(t) for t in cross(self)) @@ -357,7 +387,7 @@ def render(self) -> str: @dataclasses.dataclass(frozen=True) -class QuantifiableReference(Space): +class QuantifiableReference(ScalarSpace): identifier: AliasIdentifier subscripts: tuple[Expression, ...] quantifiers: tuple[QuantifierIdentifier, ...] @@ -382,7 +412,30 @@ def render(self, _precedence=0) -> str: return self.identifier.format() -def expression_space(expr: Expression) -> Optional[Space]: +_Q = TypeVar( + "_Q", bound=Union[Quantifier, tuple[Quantifier, ...]], covariant=True +) + + +class IterableSpace(Protocol[_Q]): + """Base protocol for spaces which can also be directly iterated on + + It is exposed mostly as a typing convenience for typing model fragments. + :class:`~opvious.modeling.Space` is typically used for providing the + underlying implementation. + """ + + def __mul__(self, other: Quantifiable) -> Quantification: + raise NotImplementedError() + + def __rmul__(self, other: Quantifiable) -> Quantification: + raise NotImplementedError() + + def __iter__(self) -> Iterator[_Q]: + raise NotImplementedError() + + +def expression_space(expr: Expression) -> Optional[ScalarSpace]: """Returns the underlying scalar quantifiable for an expression if any""" if isinstance(expr, Quantifier): return expr.identifier.space @@ -512,7 +565,7 @@ def domain( names: Optional[Iterable[Name]] = None, ) -> Domain: """Creates a domain from a quantifiable""" - return _domain_from_quantified(cross(quantifiable, names=names)) + return _domain_from_quantified(iter(cross(quantifiable, names=names))) def _domain_from_quantified( @@ -552,11 +605,36 @@ def lifted(self) -> tuple[Quantifier, ...]: raise Exception("Unlifted cross-product") return self._lifted + def __getitem__(self, ix) -> Quantifier: + return self._quantifiers[ix] + def __iter__(self): return iter(self._quantifiers) -Quantification = Quantified[Cross] +@dataclasses.dataclass(frozen=True) +class Quantification(Space): + quantifiables: tuple[Quantifiable, ...] + names: Mapping[int, Name] + projection: Projection + lift: bool + + def __iter__(self) -> Quantified[Cross]: + projected: list[Quantifier] = [] + lifted: list[Quantifier] = [] + for i, d in enumerate(self.quantifiables): + project = (1 << i) & self.projection + if not project and not self.lift: + continue + j0 = len(projected) + quants = list( + Quantifier(declare(iden.named(self.names.get(j0 + j)))) + for j, iden in enumerate(_quantifier_identifiers(d)) + ) + lifted.extend(quants) + if project: + projected.extend(quants) + yield Cross(tuple(projected), tuple(lifted)) def cross( @@ -571,28 +649,18 @@ def cross( quantifiables: One or more quantifiables names: Optional names for the generated quantifiers projection: Quantifiable selection mask - lift: Returns a lifted :class:`~opvious.modeling.Cross` instance. + lift: Returns lifted :class:`~opvious.modeling.Cross` instances. Setting this option will include all masks present in the original quantifiable, even if they are not projected. This function is the core building block for quantifying values. """ - names_by_index = dict(enumerate(names or [])) - projected: list[Quantifier] = [] - lifted: list[Quantifier] = [] - for i, q in enumerate(quantifiables): - project = (1 << i) & projection - if not project and not lift: - continue - j0 = len(projected) - quants = list( - Quantifier(declare(iden.named(names_by_index.get(j0 + j)))) - for j, iden in enumerate(_quantifier_identifiers(q)) - ) - lifted.extend(quants) - if project: - projected.extend(quants) - yield Cross(tuple(projected), tuple(lifted)) + return Quantification( + quantifiables=quantifiables, + names=dict(enumerate(names or [])), + projection=projection, + lift=lift, + ) def _quantifier_identifiers( @@ -610,7 +678,7 @@ def _quantifier_identifiers( ) for q in qs: yield q.grouped_within(group) - elif isinstance(quantifiable, Space): + elif isinstance(quantifiable, ScalarSpace): yield QuantifierIdentifier.base(quantifiable) else: # domain or quantified if isinstance(quantifiable, Domain): diff --git a/opvious/modeling/definitions.py b/opvious/modeling/definitions.py index 2ece58f..208769a 100644 --- a/opvious/modeling/definitions.py +++ b/opvious/modeling/definitions.py @@ -10,6 +10,7 @@ Any, Callable, Iterable, + Iterator, Literal, Optional, Sequence, @@ -25,17 +26,19 @@ Expression, ExpressionLike, ExpressionReference, - literal, + IterableSpace, Predicate, - Space, Quantifiable, QuantifiableReference, Quantifier, QuantifierIdentifier, + ScalarSpace, + Space, cross, domain, expression_space, is_literal, + literal, render_identifier, to_expression, within_domain, @@ -55,7 +58,7 @@ _logger = logging.getLogger(__name__) -class Dimension(Definition, Space): +class Dimension(Definition, ScalarSpace): """An abstract collection of values Args: @@ -115,7 +118,7 @@ def render_statement(self, label: Label) -> Optional[str]: @dataclasses.dataclass(frozen=True) -class _Interval(Space): +class _Interval(ScalarSpace): lower_bound: Expression upper_bound: Expression @@ -139,7 +142,7 @@ def interval( lower_bound: ExpressionLike, upper_bound: ExpressionLike, name: Optional[Name] = None, -) -> Iterable[Quantifier]: +) -> IterableSpace[Quantifier]: """A range of values Args: @@ -152,13 +155,13 @@ def interval( upper_bound=to_expression(upper_bound), ) - class _Fragment(ModelFragment): + class _Fragment(ModelFragment, Space): @property @alias(name) def interval(self): return interval - def __iter__(self) -> Quantified[Quantifier]: + def __iter__(self) -> Iterator[Quantifier]: return iter(self.interval) return _Fragment() @@ -204,7 +207,7 @@ class Tensor(Definition): Calling a tensor returns an :class:`~.opvious.modeling.Expression` with any arguments as subscripts. For example: - .. code:: python + .. code-block:: python class ProductModel(Model): products = Dimension() @@ -364,7 +367,7 @@ class Parameter(Tensor): Consider instantiating parameters via one of the various :class:`~opvious.modeling.Tensor` convenience class methods, for example: - .. code:: python + .. code-block:: python p1 = Parameter.continuous() # Real-valued parameter p2 = Parameter.natural() # # Parameter with values in {0, 1...} @@ -392,7 +395,7 @@ class Variable(Tensor): various :class:`~opvious.modeling.Tensor` convenience class methods, for example: - .. code:: python + .. code-block:: python v1 = Variable.unit() # Variable with value within [0, 1] v2 = Variable.non_negative() # # Variable with value at least 0 @@ -408,7 +411,7 @@ class Variable(Tensor): @dataclasses.dataclass(frozen=True) class _Aliased: - quantifiables: Sequence[Optional[Space]] + quantifiables: Sequence[Optional[ScalarSpace]] quantifiers: Union[ None, QuantifierIdentifier, tuple[QuantifierIdentifier, ...] ] diff --git a/opvious/modeling/identifiers.py b/opvious/modeling/identifiers.py index f44d96c..af64954 100644 --- a/opvious/modeling/identifiers.py +++ b/opvious/modeling/identifiers.py @@ -58,7 +58,7 @@ class QuantifierGroup: class QuantifierIdentifier(Identifier): - space: Any # Space + space: Any # ScalarSpace groups: Sequence[QuantifierGroup] name: Optional[Name] diff --git a/opvious/modeling/quantified.py b/opvious/modeling/quantified.py index aeccf94..2daec3b 100644 --- a/opvious/modeling/quantified.py +++ b/opvious/modeling/quantified.py @@ -3,13 +3,13 @@ import contextvars import dataclasses import itertools -from typing import Any, Generator, Tuple, TypeVar +from typing import Any, Iterator, Tuple, TypeVar _V = TypeVar("_V") -Quantified = Generator[_V, None, None] +Quantified = Iterator[_V] def _run_quantified(quantified: Quantified[_V]) -> _V: diff --git a/pyproject.toml b/pyproject.toml index 2e861e9..1c7ea4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "opvious" -version = "0.12.5rc3" +version = "0.12.6rc1" description = "Opvious Python SDK" authors = ["Opvious Engineering "] readme = "README.md" diff --git a/tests/test_executors.py b/tests/test_executors.py index a193c39..1ea3d30 100644 --- a/tests/test_executors.py +++ b/tests/test_executors.py @@ -32,7 +32,8 @@ async def test_execute_ok(self, executor): async def test_execute_missing_argument(self, executor): with pytest.raises(opvious.executors.ExecutorError) as info: await executor.execute_graphql_query("@PaginateFormulations") - assert info.value.status == 400 + assert info.value.status == 200 + assert _first_exception_code(info.value.data) == "ERR_INVALID" @pytest.mark.asyncio @pytest.mark.parametrize("executor", _executors) @@ -42,4 +43,9 @@ async def test_execute_not_found(self, executor): "@CancelAttempt", {"uuid": "00000000-0000-0000-0000-000000000000"}, ) - assert info.value.status == 404 + assert info.value.status == 200 + assert _first_exception_code(info.value.data) == "ERR_UNKNOWN_ATTEMPT" + + +def _first_exception_code(data): + return data["errors"][0]["extensions"]["exception"]["code"] diff --git a/tests/test_modeling.py b/tests/test_modeling.py index 03e8978..1ce2ace 100644 --- a/tests/test_modeling.py +++ b/tests/test_modeling.py @@ -113,7 +113,7 @@ def transfer_count_is_below_max(self) -> om.Quantified[om.Predicate]: @om.constraint def transfer_is_above_floor(self) -> om.Quantified[om.Predicate]: - for s, r in om.cross(self.friends, self.friends): + for s, r in self.friends * self.friends: floor = self.floor() * self.tranferred_indicator(s, r) yield self.transferred(s, r) >= floor @@ -124,8 +124,7 @@ def minimize_transfer_count(self) -> om.Expression: @om.objective def minimize_total_transferred(self) -> om.Expression: return om.total( - self.transferred(s, r) - for s, r in om.cross(self.friends, self.friends) + self.transferred(s, r) for s, r in self.friends * self.friends ) @@ -137,22 +136,22 @@ def __init__(self) -> None: self.positions = om.interval(0, 8, name="P") self.input = om.Parameter.indicator( - (self.grid, self.values), + self.grid * self.values, qualifiers=self._qualifiers, ) self.output = om.Variable.indicator( - (self.grid, self.values), + self.grid * self.values, qualifiers=self._qualifiers, ) @property @om.alias("G") def grid(self) -> om.Quantification: - return om.cross(self.positions, self.positions) + return self.positions * self.positions @om.constraint(qualifiers=_qualifiers) def output_matches_input(self): - for i, j, v in om.cross(self.positions, self.positions, self.values): + for i, j, v in self.grid * self.values: if self.input(i, j, v): yield self.output(i, j, v) >= self.input(i, j, v) @@ -163,17 +162,17 @@ def one_output_per_cell(self): @om.constraint def one_value_per_column(self): - for j, v in om.cross(self.positions, self.values): + for j, v in self.positions * self.values: yield om.total(self.output(i, j, v) == 1 for i in self.positions) @om.constraint def one_value_per_row(self): - for i, v in om.cross(self.positions, self.values): + for i, v in self.positions * self.values: yield om.total(self.output(i, j, v) == 1 for j in self.positions) @om.constraint def one_value_per_box(self): - for v, b in om.cross(self.values, self.positions): + for v, b in self.values * self.positions: yield om.total( self.output(3 * (b // 3) + c // 3, 3 * (b % 3) + c % 3, v) == 1 for c in self.positions