diff --git a/dwave/optimization/_model.pxd b/dwave/optimization/_model.pxd new file mode 100644 index 00000000..9585d7c1 --- /dev/null +++ b/dwave/optimization/_model.pxd @@ -0,0 +1,86 @@ +# Copyright 2024 D-Wave Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from libc.stdint cimport uintptr_t +from libcpp cimport bool +from libcpp.memory cimport shared_ptr +from libcpp.vector cimport vector + +from dwave.optimization.libcpp.graph cimport ArrayNode as cppArrayNode, Node as cppNode +from dwave.optimization.libcpp.graph cimport Graph as cppGraph +from dwave.optimization.libcpp.state cimport State as cppState + +__all__ = [] + + +cdef class _Graph: + cpdef bool is_locked(self) noexcept + cpdef Py_ssize_t num_constraints(self) noexcept + cpdef Py_ssize_t num_decisions(self) noexcept + cpdef Py_ssize_t num_nodes(self) noexcept + cpdef Py_ssize_t num_symbols(self) noexcept + + # Make the _Graph class weak referenceable + cdef object __weakref__ + + cdef cppGraph _graph + + # The number of times "lock()" has been called. + cdef readonly Py_ssize_t _lock_count + + # Used to keep NumPy arrays that own data alive etc etc + # We could pair each of these with an expired_ptr for the node holding + # memory for easier cleanup later if that becomes a concern. + cdef object _data_sources + + +cdef class Symbol: + # Inheriting nodes must call this method from their __init__() + cdef void initialize_node(self, _Graph model, cppNode* node_ptr) noexcept + + cpdef uintptr_t id(self) noexcept + + # Exactly deref(self.expired_ptr) + cpdef bool expired(self) noexcept + + @staticmethod + cdef Symbol from_ptr(_Graph model, cppNode* ptr) + + # Hold on to a reference to the _Graph, both for access but also, importantly, + # to ensure that the model doesn't get garbage collected unless all of + # the observers have also been garbage collected. + cdef readonly _Graph model + + # Hold Node* pointer. This is redundant as most observers will also hold + # a pointer to their observed node with the correct type. But the cost + # of a redundant pointer is quite small for these Python objects and it + # simplifies things quite a bit. + cdef cppNode* node_ptr + + # The node's expired flag. If the node is destructed, the boolean value + # pointed to by the expired_ptr will be set to True + cdef shared_ptr[bool] expired_ptr + + +# Ideally this wouldn't subclass Symbol, but Cython only allows a single +# extension base class, so to support that we assume all ArraySymbols are +# also Symbols (probably a fair assumption) +cdef class ArraySymbol(Symbol): + # Inheriting symbols must call this method from their __init__() + cdef void initialize_arraynode(self, _Graph model, cppArrayNode* array_ptr) noexcept + + # Hold ArrayNode* pointer. Again this is redundant, because we're also holding + # a pointer to Node* and we can theoretically dynamic cast each time. + # But again, it's cheap and it simplifies things. + cdef cppArrayNode* array_ptr diff --git a/dwave/optimization/model.pyi b/dwave/optimization/_model.pyi similarity index 70% rename from dwave/optimization/model.pyi rename to dwave/optimization/_model.pyi index 1b840692..7e9d0f57 100644 --- a/dwave/optimization/model.pyi +++ b/dwave/optimization/_model.pyi @@ -18,51 +18,29 @@ import tempfile import typing import numpy -import numpy.typing +from dwave.optimization.states import States from dwave.optimization.symbols import * _ShapeLike: typing.TypeAlias = typing.Union[int, collections.abc.Sequence[int]] +_GraphSubclass = typing.TypeVar("_GraphSubclass", bound="_Graph") -class Model: - def __init__(self): ... - @property - def objective(self) -> ArraySymbol: ... - @property - def states(self) -> States: ... +class _Graph: + def __init__(self, *args, **kwargs) -> typing.NoReturn: ... def add_constraint(self, value: ArraySymbol) -> ArraySymbol: ... - def binary(self, shape: typing.Optional[_ShapeLike] = None) -> BinaryVariable: ... - def constant(self, array_like: numpy.typing.ArrayLike) -> Constant: ... def decision_state_size(self) -> int: ... - def disjoint_bit_sets( - self, primary_set_size: int, num_disjoint_sets: int, - ) -> tuple[DisjointBitSets, tuple[DisjointBitSet, ...]]: ... - - def disjoint_lists( - self, primary_set_size: int, num_disjoint_lists: int, - ) -> tuple[DisjointLists, tuple[DisjointList, ...]]: ... - - def feasible(self, index: int = 0) -> bool: ... - @classmethod def from_file( - cls, + cls: typing.Type[_GraphSubclass], file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], *, check_header: bool = True, - ) -> Model: ... - - def integer( - self, - shape: typing.Optional[_ShapeLike] = None, - lower_bound: typing.Optional[int] = None, - upper_bound: typing.Optional[int] = None, - ) -> IntegerVariable: ... + ) -> _GraphSubclass: ... def into_file( self, @@ -76,39 +54,14 @@ class Model: def iter_constraints(self) -> collections.abc.Iterator[ArraySymbol]: ... def iter_decisions(self) -> collections.abc.Iterator[Symbol]: ... def iter_symbols(self) -> collections.abc.Iterator[Symbol]: ... - def list(self, n: int) -> ListVariable: ... - def lock(self) -> contextlib.AbstractContextManager: ... + def lock(self): ... def minimize(self, value: ArraySymbol): ... def num_constraints(self) -> int: ... def num_decisions(self) -> int: ... def num_nodes(self) -> int: ... def num_symbols(self) -> int: ... - - # dev note: this is underspecified, but it would be quite complex to fully - # specify the linear/quadratic so let's leave it alone for now. - def quadratic_model(self, x: ArraySymbol, quadratic, linear=None) -> QuadraticModel: ... - def remove_unused_symbols(self) -> int: ... - - def set( - self, - n: int, - min_size: int = 0, - max_size: typing.Optional[int] = None, - ) -> SetVariable: ... - def state_size(self) -> int: ... - - def to_file( - self, - *, - max_num_states: int = 0, - only_decision: bool = False, - ) -> typing.BinaryIO: ... - - # networkx might not be installed, so we just say we return an object. - def to_networkx(self) -> object: ... - def unlock(self): ... diff --git a/dwave/optimization/model.pyx b/dwave/optimization/_model.pyx similarity index 78% rename from dwave/optimization/model.pyx rename to dwave/optimization/_model.pyx index c220f455..3ffc592d 100644 --- a/dwave/optimization/model.pyx +++ b/dwave/optimization/_model.pyx @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,18 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Nonlinear models are especially suited for use with decision variables that -represent a common logic, such as subsets of choices or permutations of ordering. -For example, in a -`traveling salesperson problem `_ -permutations of the variables representing cities can signify the order of the -route being optimized and in a -`knapsack problem `_ the -variables representing items can be divided into subsets of packed and not -packed. -""" - -import contextlib import collections.abc import functools import itertools @@ -31,7 +19,6 @@ import json import numbers import operator import struct -import tempfile import zipfile import numpy as np @@ -49,44 +36,24 @@ from dwave.optimization.states cimport States from dwave.optimization.states import StateView from dwave.optimization.symbols cimport symbol_from_ptr +__all__ = [] -__all__ = ["Model"] +cdef class _Graph: + """A ``_Graph`` is a class that manages a C++ ``dwave::optimization::Graph``. -@contextlib.contextmanager -def locked(model): - """Context manager that hold a locked model and unlocks it when the context is exited.""" - try: - yield - finally: - model.unlock() - - -cdef class Model: - """Nonlinear model. - - The nonlinear model represents a general optimization problem with an - :term:`objective function` and/or constraints over variables of various - types. - - The :class:`.Model` class can contain this model and its methods provide - convenient utilities for working with representations of a problem. - - Examples: - This example creates a model for a - :class:`flow-shop-scheduling ` - problem with two jobs on three machines. - - >>> from dwave.optimization.generators import flow_shop_scheduling - ... - >>> processing_times = [[10, 5, 7], [20, 10, 15]] - >>> model = flow_shop_scheduling(processing_times=processing_times) + It is not intended for a user to use ``_Graph`` directly. Rather, classes + may inherit from ``_Graph``. """ - def __init__(self): - self.states = States(self) - + def __cinit__(self): + self._lock_count = 0 self._data_sources = [] + def __init__(self, *args, **kwargs): + # disallow direct construction of _Graphs, they should be constructed + # via their subclasses. + raise ValueError("_Graphs cannot be constructed directly") + def add_constraint(self, ArraySymbol value): """Add a constraint to the model. @@ -125,47 +92,6 @@ cdef class Model: self._graph.add_constraint(value.array_ptr) return value - def binary(self, shape=None): - r"""Create a binary symbol as a decision variable. - - Args: - shape: Shape of the binary array to create. - - Returns: - A binary symbol. - - Examples: - This example creates a :math:`1 \times 20`-sized binary symbol. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> x = model.binary((1,20)) - """ - from dwave.optimization.symbols import BinaryVariable #avoid circular import - return BinaryVariable(self, shape) - - def constant(self, array_like): - r"""Create a constant symbol. - - Args: - array_like: An |array-like|_ representing a constant. Can be a scalar - or a NumPy array. If the array's ``dtype`` is ``np.double``, the - array is not copied. - - Returns: - A constant symbol. - - Examples: - This example creates a :math:`1 \times 4`-sized constant symbol - with the specified values. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> time_limits = model.constant([10, 15, 5, 8.5]) - """ - from dwave.optimization.symbols import Constant # avoid circular import - return Constant(self, array_like) - def decision_state_size(self): r"""An estimated size, in bytes, of the model's decision states. @@ -181,102 +107,6 @@ cdef class Model: """ return sum(sym.state_size() for sym in self.iter_decisions()) - def disjoint_bit_sets(self, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_sets): - """Create a disjoint-sets symbol as a decision variable. - - Divides a set of the elements of ``range(primary_set_size)`` into - ``num_disjoint_sets`` ordered partitions, stored as bit sets (arrays - of length ``primary_set_size``, with ones at the indices of elements - currently in the set, and zeros elsewhere). The ordering of a set is - not semantically meaningful. - - Also creates from the symbol ``num_disjoint_sets`` extra successors - that output the disjoint sets as arrays. - - Args: - primary_set_size: Number of elements in the primary set that are - partitioned into disjoint sets. Must be non-negative. - num_disjoint_sets: Number of disjoint sets. Must be positive. - - Returns: - A tuple where the first element is the disjoint-sets symbol and - the second is a set of its newly added successors. - - Examples: - This example creates a symbol of 10 elements that is divided - into 4 sets. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> parts_set, parts_subsets = model.disjoint_bit_sets(10, 4) - """ - - from dwave.optimization.symbols import DisjointBitSets, DisjointBitSet # avoid circular import - main = DisjointBitSets(self, primary_set_size, num_disjoint_sets) - sets = tuple(DisjointBitSet(main, i) for i in range(num_disjoint_sets)) - return main, sets - - def disjoint_lists(self, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_lists): - """Create a disjoint-lists symbol as a decision variable. - - Divides a set of the elements of ``range(primary_set_size)`` into - ``num_disjoint_lists`` ordered partitions. - - Also creates ``num_disjoint_lists`` extra successors from the - symbol that output the disjoint lists as arrays. - - Args: - primary_set_size: Number of elements in the primary set to - be partitioned into disjoint lists. - num_disjoint_lists: Number of disjoint lists. - - Returns: - A tuple where the first element is the disjoint-lists symbol - and the second is a list of its newly added successor nodes. - - Examples: - This example creates a symbol of 10 elements that is divided - into 4 lists. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> destinations, routes = model.disjoint_lists(10, 4) - """ - from dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import - main = DisjointLists(self, primary_set_size, num_disjoint_lists) - lists = [DisjointList(main, i) for i in range(num_disjoint_lists)] - return main, lists - - def feasible(self, int index = 0): - """Check the feasibility of the state at the input index. - - Args: - index: index of the state to check for feasibility. - - Returns: - Feasibility of the state. - - Examples: - This example demonstrates checking the feasibility of a simple model with - feasible and infeasible states. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> b = model.binary() - >>> model.add_constraint(b) # doctest: +ELLIPSIS - - >>> model.states.resize(2) - >>> b.set_state(0, 1) # Feasible - >>> b.set_state(1, 0) # Infeasible - >>> with model.lock(): - ... model.feasible(0) - True - >>> with model.lock(): - ... model.feasible(1) - False - """ - return all(sym.state(index) for sym in self.iter_constraints()) - @classmethod def from_file(cls, file, *, check_header = True, @@ -315,7 +145,7 @@ cdef class Model: header_len = struct.unpack('>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer((20,20), lower_bound=-100, upper_bound=100) - """ - from dwave.optimization.symbols import IntegerVariable #avoid circular import - return IntegerVariable(self, shape, lower_bound, upper_bound) - def _header_data(self, *, only_decision, max_num_states=float('inf')): """The header data associated with the model (but not the states).""" num_nodes = self.num_decisions() if only_decision else self.num_nodes() - num_states = max(0, min(self.states.size(), max_num_states)) + try: + num_states = max(0, min(self.states.size(), max_num_states)) + except AttributeError: + num_states = 0 decision_state_size = self.decision_state_size() state_size = decision_state_size if only_decision else self.state_size() @@ -613,60 +420,10 @@ cdef class Model: for i in range(nodes.size()): yield symbol_from_ptr(self, nodes[i].get()) - def list(self, n : int): - """Create a list symbol as a decision variable. - - Args: - n: Values in the list are permutations of ``range(n)``. - - Returns: - A list symbol. - - Examples: - This example creates a list symbol of 200 elements. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> routes = model.list(200) - """ - from dwave.optimization.symbols import ListVariable # avoid circular import - return ListVariable(self, n) - def lock(self): """Lock the model. No new symbols can be added to a locked model. - - Returns: - A context manager. If the context is subsequently exited then the - :meth:`.unlock` will be called. - - See also: - :meth:`.is_locked`, :meth:`.unlock` - - Examples: - This example checks the status of a model after locking it and - subsequently unlocking it. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer(20, upper_bound=100) - >>> cntx = model.lock() - >>> model.is_locked() - True - >>> model.unlock() - >>> model.is_locked() - False - - This example locks a model temporarily with a context manager. - - >>> model = Model() - >>> with model.lock(): - ... # no nodes can be added within the context - ... print(model.is_locked()) - True - >>> model.is_locked() - False """ self._graph.topological_sort() # does nothing if already sorted, so safe to call always self._lock_count += 1 @@ -674,8 +431,6 @@ cdef class Model: # note that we do not initialize the nodes or resize the states! # We do it lazily for performance - return locked(self) - def minimize(self, ArraySymbol value): """Set the objective value to minimize. @@ -706,7 +461,6 @@ cdef class Model: if value.size() > 1: raise ValueError("the value of an array with more than one element is ambiguous") self._graph.set_objective(value.array_ptr) - self.objective = value cpdef Py_ssize_t num_constraints(self) noexcept: """Number of constraints in the model. @@ -788,7 +542,7 @@ cdef class Model: """ return self._graph.num_nodes() - def num_symbols(self): + cpdef Py_ssize_t num_symbols(self) noexcept: """Number of symbols tracked by the model. Equivalent to the number of nodes in the directed acyclic @@ -811,32 +565,6 @@ cdef class Model: """ return self.num_nodes() - def quadratic_model(self, ArraySymbol x, quadratic, linear=None): - """Create a quadratic model from an array and a quadratic model. - - Args: - x: An array. - - quadratic: Quadratic values for the quadratic model. - - linear: Linear values for the quadratic model. - - Returns: - A quadratic model. - - Examples: - This example creates a quadratic model. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> x = model.binary(3) - >>> Q = {(0, 0): 0, (0, 1): 1, (0, 2): 2, (1, 1): 1, (1, 2): 3, (2, 2): 2} - >>> qm = model.quadratic_model(x, Q) - - """ - from dwave.optimization.symbols import QuadraticModel - return QuadraticModel(x, quadratic, linear) - def remove_unused_symbols(self): """Remove unused symbols from the model. @@ -890,28 +618,6 @@ cdef class Model: raise ValueError("cannot remove symbols from a locked model") return self._graph.remove_unused_nodes() - def set(self, Py_ssize_t n, Py_ssize_t min_size = 0, max_size = None): - """Create a set symbol as a decision variable. - - Args: - n: Values in the set are subsets of ``range(n)``. - min_size: Minimum set size. Defaults to ``0``. - max_size: Maximum set size. Defaults to ``n``. - - Returns: - A set symbol. - - Examples: - This example creates a set symbol of up to 4 elements - with values between 0 to 99. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> destinations = model.set(100, max_size=4) - """ - from dwave.optimization.symbols import SetVariable # avoid circular import - return SetVariable(self, n, min_size, n if max_size is None else max_size) - def state_size(self): """An estimate of the size, in bytes, of all states in the model. @@ -929,88 +635,6 @@ cdef class Model: """ return sum(sym.state_size() for sym in self.iter_symbols()) - def to_file(self, **kwargs): - """Serialize the model to a new file-like object. - - See also: - :meth:`.into_file`, :meth:`.from_file` - """ - file = tempfile.TemporaryFile(mode="w+b") - self.into_file(file, **kwargs) - file.seek(0) - return file - - def to_networkx(self): - """Convert the model to a NetworkX graph. - - Returns: - A :obj:`NetworkX ` graph. - - Examples: - This example converts a model to a graph. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> one = model.constant(1) - >>> two = model.constant(2) - >>> i = model.integer() - >>> model.minimize(two * i - one) - >>> G = model.to_networkx() # doctest: +SKIP - - One advantage of converting to NetworkX is the wide availability - of drawing tools. See NetworkX's - `drawing `_ - documentation. - - This example uses `DAGVIZ `_ to - draw the NetworkX graph created in the example above. - - >>> import dagviz # doctest: +SKIP - >>> r = dagviz.render_svg(G) # doctest: +SKIP - >>> with open("model.svg", "w") as f: # doctest: +SKIP - ... f.write(r) - - This creates the following image: - - .. figure:: /_images/to_networkx_example.svg - :width: 500 px - :name: dwave-optimization-to-networkx-example - :alt: Image of NetworkX Directed Graph - - """ - import networkx - - G = networkx.MultiDiGraph() - - # Add the symbols, in order if we happen to be topologically sorted - G.add_nodes_from(repr(symbol) for symbol in self.iter_symbols()) - - # Sanity check. If several nodes map to the same symbol repr we'll see - # too few nodes in the graph - if len(G) != self.num_symbols(): - raise RuntimeError("symbol repr() is not unique to the underlying node") - - # Now add the edges - for symbol in self.iter_symbols(): - for successor in symbol.iter_successors(): - G.add_edge(repr(symbol), repr(successor)) - - # Sanity check again. If the repr of symbols isn't unique to the underlying - # node then we'll see too many nodes in the graph here - if len(G) != self.num_symbols(): - raise RuntimeError("symbol repr() is not unique to the underlying node") - - # Add the objective if it's present. We call it "minimize" to be - # consistent with the minimize() function - if self.objective is not None: - G.add_edge(repr(self.objective), "minimize") - - # Likewise if we have constraints, add a special node for them - for symbol in self.iter_constraints(): - G.add_edge(repr(symbol), "constraint(s)") - - return G - def unlock(self): """Release a lock, decrementing the lock count. @@ -1024,16 +648,10 @@ cdef class Model: self._lock_count -= 1 - cdef States states = self.states # for Cython access - # if we're now unlocked, then reset the topological sort and the # non-decision states if self._lock_count < 1: self._graph.reset_topological_sort() - for i in range(states.size()): - # this might actually increase the size of the states in some - # cases, but that's fine - states._states[i].resize(self.num_decisions()) cdef class Symbol: @@ -1056,7 +674,7 @@ cdef class Symbol: cls = type(self) return f"<{cls.__module__}.{cls.__qualname__} at {self.id():#x}>" - cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept: + cdef void initialize_node(self, _Graph model, cppNode* node_ptr) noexcept: self.model = model self.node_ptr = node_ptr @@ -1087,7 +705,7 @@ cdef class Symbol: return deref(self.expired_ptr) @staticmethod - cdef Symbol from_ptr(Model model, cppNode* ptr): + cdef Symbol from_ptr(_Graph model, cppNode* ptr): """Construct a Symbol from a C++ Node pointer. There are times when a Node* needs to be passed through the Python layer @@ -1108,7 +726,7 @@ cdef class Symbol: raise ValueError("Symbols cannot be constructed directly") @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a symbol from a compressed file. Args: @@ -1143,6 +761,9 @@ cdef class Symbol: raise TypeError("the state of an intermediate variable cannot be accessed without " "locking the model first. See model.lock().") + if not hasattr(self.model, "states"): + return False + cdef States states = self.model.states # for Cython access states.resolve() @@ -1492,7 +1113,7 @@ cdef class ArraySymbol(Symbol): # via their subclasses. raise ValueError("ArraySymbols cannot be constructed directly") - cdef void initialize_arraynode(self, Model model, cppArrayNode* array_ptr) noexcept: + cdef void initialize_arraynode(self, _Graph model, cppArrayNode* array_ptr) noexcept: self.array_ptr = array_ptr self.initialize_node(model, array_ptr) diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 77ef652e..1386db28 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -16,7 +16,7 @@ import functools import typing -from dwave.optimization.model import ArraySymbol +from dwave.optimization._model import ArraySymbol from dwave.optimization.symbols import ( Add, And, diff --git a/dwave/optimization/model.pxd b/dwave/optimization/model.pxd index fd501502..8d0a63b9 100644 --- a/dwave/optimization/model.pxd +++ b/dwave/optimization/model.pxd @@ -1,6 +1,4 @@ -# distutils: language = c++ - -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,111 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -from libc.stdint cimport uintptr_t -from libcpp cimport bool -from libcpp.memory cimport shared_ptr -from libcpp.vector cimport vector - -from dwave.optimization.libcpp.graph cimport ArrayNode as cppArrayNode, Node as cppNode -from dwave.optimization.libcpp.graph cimport Graph as cppGraph -from dwave.optimization.libcpp.state cimport State as cppState - -__all__ = ["Model"] - - -cdef class Model: - cpdef bool is_locked(self) noexcept - cpdef Py_ssize_t num_decisions(self) noexcept - cpdef Py_ssize_t num_nodes(self) noexcept - cpdef Py_ssize_t num_constraints(self) noexcept - - # Allow dynamic attributes on the Model class - cdef dict __dict__ - - # Make the Model class weak referenceable - cdef object __weakref__ - - cdef cppGraph _graph - - cdef readonly object objective # todo: cdef ArraySymbol? - """Objective to be minimized. - - Examples: - This example prints the value of the objective of a model representing - the simple polynomial, :math:`y = i^2 - 4i`, for a state with value - :math:`i=2.0`. - - >>> from dwave.optimization import Model - ... - >>> model = Model() - >>> i = model.integer(lower_bound=-5, upper_bound=5) - >>> c = model.constant(4) - >>> y = i**2 - c*i - >>> model.minimize(y) - >>> with model.lock(): - ... model.states.resize(1) - ... i.set_state(0, 2.0) - ... print(f"Objective = {model.objective.state(0)}") - Objective = -4.0 - """ - - cdef readonly object states - """States of the model. - - :ref:`States ` represent assignments of values - to a symbol. - - See also: - :ref:`States methods ` such as - :meth:`~dwave.optimization.model.States.size` and - :meth:`~dwave.optimization.model.States.resize`. - """ - - # The number of times "lock()" has been called. - cdef readonly Py_ssize_t _lock_count - - # Used to keep NumPy arrays that own data alive etc etc - # We could pair each of these with an expired_ptr for the node holding - # memory for easier cleanup later if that becomes a concern. - cdef object _data_sources - - -cdef class Symbol: - # Inheriting nodes must call this method from their __init__() - cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept - - cpdef uintptr_t id(self) noexcept - - # Exactly deref(self.expired_ptr) - cpdef bool expired(self) noexcept - - @staticmethod - cdef Symbol from_ptr(Model model, cppNode* ptr) - - # Hold on to a reference to the Model, both for access but also, importantly, - # to ensure that the model doesn't get garbage collected unless all of - # the observers have also been garbage collected. - cdef readonly Model model - - # Hold Node* pointer. This is redundant as most observers will also hold - # a pointer to their observed node with the correct type. But the cost - # of a redundant pointer is quite small for these Python objects and it - # simplifies things quite a bit. - cdef cppNode* node_ptr - - # The node's expired flag. If the node is destructed, the boolean value - # pointed to by the expired_ptr will be set to True - cdef shared_ptr[bool] expired_ptr - - -# Ideally this wouldn't subclass Symbol, but Cython only allows a single -# extension base class, so to support that we assume all ArraySymbols are -# also Symbols (probably a fair assumption) -cdef class ArraySymbol(Symbol): - # Inheriting symbols must call this method from their __init__() - cdef void initialize_arraynode(self, Model model, cppArrayNode* array_ptr) noexcept - - # Hold ArrayNode* pointer. Again this is redundant, because we're also holding - # a pointer to Node* and we can theoretically dynamic cast each time. - # But again, it's cheap and it simplifies things. - cdef cppArrayNode* array_ptr +from dwave.optimization._model cimport ArraySymbol, _Graph, Symbol diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py new file mode 100644 index 00000000..346197db --- /dev/null +++ b/dwave/optimization/model.py @@ -0,0 +1,499 @@ +# Copyright 2024 D-Wave Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Nonlinear models are especially suited for use with decision variables that +represent a common logic, such as subsets of choices or permutations of ordering. +For example, in a +`traveling salesperson problem `_ +permutations of the variables representing cities can signify the order of the +route being optimized and in a +`knapsack problem `_ the +variables representing items can be divided into subsets of packed and not +packed. +""" + +from __future__ import annotations + +import collections +import contextlib +import tempfile +import typing + +from dwave.optimization._model import ArraySymbol, _Graph, Symbol +from dwave.optimization.states import States + +if typing.TYPE_CHECKING: + import numpy.typing + + from dwave.optimization.symbols import * + + _ShapeLike: typing.TypeAlias = typing.Union[int, collections.abc.Sequence[int]] + +__all__ = ["Model"] + + +@contextlib.contextmanager +def locked(model: _Graph): + """Context manager that hold a locked model and unlocks it when the context is exited.""" + try: + yield + finally: + model.unlock() + + +class Model(_Graph): + """Nonlinear model. + + The nonlinear model represents a general optimization problem with an + :term:`objective function` and/or constraints over variables of various + types. + + The :class:`.Model` class can contain this model and its methods provide + convenient utilities for working with representations of a problem. + + Examples: + This example creates a model for a + :class:`flow-shop-scheduling ` + problem with two jobs on three machines. + + >>> from dwave.optimization.generators import flow_shop_scheduling + ... + >>> processing_times = [[10, 5, 7], [20, 10, 15]] + >>> model = flow_shop_scheduling(processing_times=processing_times) + """ + + objective: typing.Optional[ArraySymbol] + """Objective to be minimized. + + Examples: + This example prints the value of the objective of a model representing + the simple polynomial, :math:`y = i^2 - 4i`, for a state with value + :math:`i=2.0`. + + >>> from dwave.optimization import Model + ... + >>> model = Model() + >>> i = model.integer(lower_bound=-5, upper_bound=5) + >>> c = model.constant(4) + >>> y = i**2 - c*i + >>> model.minimize(y) + >>> with model.lock(): + ... model.states.resize(1) + ... i.set_state(0, 2.0) + ... print(f"Objective = {model.objective.state(0)}") + Objective = -4.0 + """ + + states: States + """States of the model. + + :ref:`States ` represent assignments of values + to a symbol. + + See also: + :ref:`States methods ` such as + :meth:`~dwave.optimization.model.States.size` and + :meth:`~dwave.optimization.model.States.resize`. + """ + + def __init__(self): + self.objective = None + self.states = States(self) + + def binary(self, shape: typing.Optional[_ShapeLike] = None) -> BinaryVariable: + r"""Create a binary symbol as a decision variable. + + Args: + shape: Shape of the binary array to create. + + Returns: + A binary symbol. + + Examples: + This example creates a :math:`1 \times 20`-sized binary symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> x = model.binary((1,20)) + """ + from dwave.optimization.symbols import BinaryVariable # avoid circular import + return BinaryVariable(self, shape) + + def constant(self, array_like: numpy.typing.ArrayLike) -> Constant: + r"""Create a constant symbol. + + Args: + array_like: An |array-like|_ representing a constant. Can be a scalar + or a NumPy array. If the array's ``dtype`` is ``np.double``, the + array is not copied. + + Returns: + A constant symbol. + + Examples: + This example creates a :math:`1 \times 4`-sized constant symbol + with the specified values. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> time_limits = model.constant([10, 15, 5, 8.5]) + """ + from dwave.optimization.symbols import Constant # avoid circular import + return Constant(self, array_like) + + def disjoint_bit_sets( + self, + primary_set_size: int, + num_disjoint_sets: int, + ) -> tuple[DisjointBitSets, tuple[DisjointBitSet, ...]]: + """Create a disjoint-sets symbol as a decision variable. + + Divides a set of the elements of ``range(primary_set_size)`` into + ``num_disjoint_sets`` ordered partitions, stored as bit sets (arrays + of length ``primary_set_size``, with ones at the indices of elements + currently in the set, and zeros elsewhere). The ordering of a set is + not semantically meaningful. + + Also creates from the symbol ``num_disjoint_sets`` extra successors + that output the disjoint sets as arrays. + + Args: + primary_set_size: Number of elements in the primary set that are + partitioned into disjoint sets. Must be non-negative. + num_disjoint_sets: Number of disjoint sets. Must be positive. + + Returns: + A tuple where the first element is the disjoint-sets symbol and + the second is a set of its newly added successors. + + Examples: + This example creates a symbol of 10 elements that is divided + into 4 sets. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> parts_set, parts_subsets = model.disjoint_bit_sets(10, 4) + """ + # avoid circular import + from dwave.optimization.symbols import DisjointBitSets, DisjointBitSet + + main = DisjointBitSets(self, primary_set_size, num_disjoint_sets) + sets = tuple(DisjointBitSet(main, i) for i in range(num_disjoint_sets)) + return main, sets + + def disjoint_lists( + self, + primary_set_size: int, + num_disjoint_lists: int, + ) -> tuple[DisjointLists, tuple[DisjointList, ...]]: + """Create a disjoint-lists symbol as a decision variable. + + Divides a set of the elements of ``range(primary_set_size)`` into + ``num_disjoint_lists`` ordered partitions. + + Also creates ``num_disjoint_lists`` extra successors from the + symbol that output the disjoint lists as arrays. + + Args: + primary_set_size: Number of elements in the primary set to + be partitioned into disjoint lists. + num_disjoint_lists: Number of disjoint lists. + + Returns: + A tuple where the first element is the disjoint-lists symbol + and the second is a list of its newly added successor nodes. + + Examples: + This example creates a symbol of 10 elements that is divided + into 4 lists. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> destinations, routes = model.disjoint_lists(10, 4) + """ + from dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import + main = DisjointLists(self, primary_set_size, num_disjoint_lists) + lists = [DisjointList(main, i) for i in range(num_disjoint_lists)] + return main, lists + + def feasible(self, index: int = 0) -> bool: + """Check the feasibility of the state at the input index. + + Args: + index: index of the state to check for feasibility. + + Returns: + Feasibility of the state. + + Examples: + This example demonstrates checking the feasibility of a simple model with + feasible and infeasible states. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> b = model.binary() + >>> model.add_constraint(b) # doctest: +ELLIPSIS + + >>> model.states.resize(2) + >>> b.set_state(0, 1) # Feasible + >>> b.set_state(1, 0) # Infeasible + >>> with model.lock(): + ... model.feasible(0) + True + >>> with model.lock(): + ... model.feasible(1) + False + """ + return all(sym.state(index) for sym in self.iter_constraints()) + + def integer( + self, + shape: typing.Optional[_ShapeLike] = None, + lower_bound: typing.Optional[int] = None, + upper_bound: typing.Optional[int] = None, + ) -> IntegerVariable: + r"""Create an integer symbol as a decision variable. + + Args: + shape: Shape of the integer array to create. + + lower_bound: Lower bound for the symbol, which is the + smallest allowed integer value. If None, the default + value is used. + upper_bound: Upper bound for the symbol, which is the + largest allowed integer value. If None, the default + value is used. + + Returns: + An integer symbol. + + Examples: + This example creates a :math:`20 \times 20`-sized integer symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer((20,20), lower_bound=-100, upper_bound=100) + """ + from dwave.optimization.symbols import IntegerVariable # avoid circular import + return IntegerVariable(self, shape, lower_bound, upper_bound) + + def list(self, n: int) -> ListVariable: + """Create a list symbol as a decision variable. + + Args: + n: Values in the list are permutations of ``range(n)``. + + Returns: + A list symbol. + + Examples: + This example creates a list symbol of 200 elements. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> routes = model.list(200) + """ + from dwave.optimization.symbols import ListVariable # avoid circular import + return ListVariable(self, n) + + def lock(self) -> contextlib.AbstractContextManager: + """Lock the model. + + No new symbols can be added to a locked model. + + Returns: + A context manager. If the context is subsequently exited then the + :meth:`.unlock` will be called. + + See also: + :meth:`.is_locked`, :meth:`.unlock` + + Examples: + This example checks the status of a model after locking it and + subsequently unlocking it. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer(20, upper_bound=100) + >>> cntx = model.lock() + >>> model.is_locked() + True + >>> model.unlock() + >>> model.is_locked() + False + + This example locks a model temporarily with a context manager. + + >>> model = Model() + >>> with model.lock(): + ... # no nodes can be added within the context + ... print(model.is_locked()) + True + >>> model.is_locked() + False + """ + super().lock() + return locked(self) + + def minimize(self, value: ArraySymbol): + # inherit the docstring from _Graph + super().minimize(value) + self.objective = value + + # dev note: the typing is underspecified, but it would be quite complex to fully + # specify the linear/quadratic so let's leave it alone for now. + def quadratic_model(self, x: ArraySymbol, quadratic, linear=None) -> QuadraticModel: + """Create a quadratic model from an array and a quadratic model. + + Args: + x: An array. + + quadratic: Quadratic values for the quadratic model. + + linear: Linear values for the quadratic model. + + Returns: + A quadratic model. + + Examples: + This example creates a quadratic model. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> x = model.binary(3) + >>> Q = {(0, 0): 0, (0, 1): 1, (0, 2): 2, (1, 1): 1, (1, 2): 3, (2, 2): 2} + >>> qm = model.quadratic_model(x, Q) + + """ + from dwave.optimization.symbols import QuadraticModel + return QuadraticModel(x, quadratic, linear) + + def set(self, + n: int, + min_size: int = 0, + max_size: typing.Optional[int] = None, + ) -> SetVariable: + """Create a set symbol as a decision variable. + + Args: + n: Values in the set are subsets of ``range(n)``. + min_size: Minimum set size. Defaults to ``0``. + max_size: Maximum set size. Defaults to ``n``. + + Returns: + A set symbol. + + Examples: + This example creates a set symbol of up to 4 elements + with values between 0 to 99. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> destinations = model.set(100, max_size=4) + """ + from dwave.optimization.symbols import SetVariable # avoid circular import + return SetVariable(self, n, min_size, n if max_size is None else max_size) + + def to_file(self, **kwargs) -> typing.BinaryIO: + """Serialize the model to a new file-like object. + + See also: + :meth:`.into_file`, :meth:`.from_file` + """ + file = tempfile.TemporaryFile(mode="w+b") + self.into_file(file, **kwargs) + file.seek(0) + return file + + # NetworkX might not be installed so we just say we return an object + def to_networkx(self) -> object: + """Convert the model to a NetworkX graph. + + Returns: + A :obj:`NetworkX ` graph. + + Examples: + This example converts a model to a graph. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> one = model.constant(1) + >>> two = model.constant(2) + >>> i = model.integer() + >>> model.minimize(two * i - one) + >>> G = model.to_networkx() # doctest: +SKIP + + One advantage of converting to NetworkX is the wide availability + of drawing tools. See NetworkX's + `drawing `_ + documentation. + + This example uses `DAGVIZ `_ to + draw the NetworkX graph created in the example above. + + >>> import dagviz # doctest: +SKIP + >>> r = dagviz.render_svg(G) # doctest: +SKIP + >>> with open("model.svg", "w") as f: # doctest: +SKIP + ... f.write(r) + + This creates the following image: + + .. figure:: /_images/to_networkx_example.svg + :width: 500 px + :name: dwave-optimization-to-networkx-example + :alt: Image of NetworkX Directed Graph + + """ + import networkx + + G = networkx.MultiDiGraph() + + # Add the symbols, in order if we happen to be topologically sorted + G.add_nodes_from(repr(symbol) for symbol in self.iter_symbols()) + + # Sanity check. If several nodes map to the same symbol repr we'll see + # too few nodes in the graph + if len(G) != self.num_symbols(): + raise RuntimeError("symbol repr() is not unique to the underlying node") + + # Now add the edges + for symbol in self.iter_symbols(): + for successor in symbol.iter_successors(): + G.add_edge(repr(symbol), repr(successor)) + + # Sanity check again. If the repr of symbols isn't unique to the underlying + # node then we'll see too many nodes in the graph here + if len(G) != self.num_symbols(): + raise RuntimeError("symbol repr() is not unique to the underlying node") + + # Add the objective if it's present. We call it "minimize" to be + # consistent with the minimize() function + if self.objective is not None: + G.add_edge(repr(self.objective), "minimize") + + # Likewise if we have constraints, add a special node for them + for symbol in self.iter_constraints(): + G.add_edge(repr(symbol), "constraint(s)") + + return G + + def unlock(self): + # inherit the docstring from _Graph + if not self.is_locked(): + return + + super().unlock() + + if not self.is_locked(): + self.states._reset_intermediate_states() diff --git a/dwave/optimization/states.pxd b/dwave/optimization/states.pxd index 7fe5b5bc..a7731363 100644 --- a/dwave/optimization/states.pxd +++ b/dwave/optimization/states.pxd @@ -15,7 +15,7 @@ from libcpp.vector cimport vector from dwave.optimization.libcpp.state cimport State as cppState -from dwave.optimization.model cimport Model +from dwave.optimization.model cimport _Graph cdef class States: @@ -24,7 +24,7 @@ cdef class States: cpdef resolve(self) cpdef Py_ssize_t size(self) except -1 - cdef Model _model(self) + cdef _Graph _model(self) # In order to not create a circular reference, we only hold a weakref # to the model from the states. This introduces some overhead, but it diff --git a/dwave/optimization/states.pyi b/dwave/optimization/states.pyi index 9381953e..6b3b3ea3 100644 --- a/dwave/optimization/states.pyi +++ b/dwave/optimization/states.pyi @@ -39,6 +39,7 @@ class States: file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], ): ... + def _reset_intermediate_states(self): ... def resize(self, n: int): ... def resolve(self): ... def size(self) -> int: ... diff --git a/dwave/optimization/states.pyx b/dwave/optimization/states.pyx index 5ca0f6f1..c05760e2 100644 --- a/dwave/optimization/states.pyx +++ b/dwave/optimization/states.pyx @@ -17,8 +17,8 @@ import weakref from libcpp.utility cimport move from dwave.optimization.libcpp.array cimport Array as cppArray -from dwave.optimization.model cimport ArraySymbol - +from dwave.optimization.model cimport ArraySymbol, _Graph +from dwave.optimization.model import Model __all__ = ["States"] @@ -75,7 +75,9 @@ cdef class States: >>> model.states.size() 0 """ - def __init__(self, Model model): + def __init__(self, model): + if not isinstance(model, Model): + raise TypeError("model must be an instance of Model") self._model_ref = weakref.ref(model) def __len__(self): @@ -158,7 +160,7 @@ cdef class States: # todo: we don't need to actually construct a model, but this is nice and # abstract. We should performance test and then potentially re-implement - cdef Model model = Model.from_file(file, check_header=check_header) + cdef _Graph model = Model.from_file(file, check_header=check_header) cdef States states = model.states # Check that the model is compatible @@ -190,7 +192,7 @@ cdef class States: """Initialize any uninitialized states.""" self.resolve() - cdef Model model = self._model() + cdef _Graph model = self._model() if not model.is_locked(): raise ValueError("Cannot initialize states of an unlocked model") @@ -213,13 +215,19 @@ cdef class States: return self._model().into_file(file, only_decision=True, max_num_states=self.size()) - cdef Model _model(self): + cdef _Graph _model(self): """Get a ref-counted Model object.""" - cdef Model m = self._model_ref() + cdef _Graph m = self._model_ref() if m is None: raise ReferenceError("accessing the states of a garbage collected model") return m + def _reset_intermediate_states(self): + """Reset any non-decision states.""" + cdef Py_ssize_t num_decisions = self._model().num_decisions() + for i in range(self.size()): + self._states[i].resize(num_decisions) + def resize(self, Py_ssize_t n): """Resize the number of states. diff --git a/dwave/optimization/symbols.pxd b/dwave/optimization/symbols.pxd index 88cb00eb..1a7db88a 100644 --- a/dwave/optimization/symbols.pxd +++ b/dwave/optimization/symbols.pxd @@ -16,10 +16,10 @@ from libcpp.typeinfo cimport type_info -from dwave.optimization.model cimport Model from dwave.optimization.libcpp.graph cimport Array as cppArray from dwave.optimization.libcpp.graph cimport Node as cppNode +from dwave.optimization.model cimport _Graph cdef void _register(object cls, const type_info& typeinfo) -cdef object symbol_from_ptr(Model model, cppNode* ptr) +cdef object symbol_from_ptr(_Graph model, cppNode* ptr) diff --git a/dwave/optimization/symbols.pyx b/dwave/optimization/symbols.pyx index a751a293..ce698123 100644 --- a/dwave/optimization/symbols.pyx +++ b/dwave/optimization/symbols.pyx @@ -96,7 +96,7 @@ from dwave.optimization.libcpp.nodes cimport ( WhereNode as cppWhereNode, XorNode as cppXorNode, ) -from dwave.optimization.model cimport ArraySymbol, Model, Symbol +from dwave.optimization.model cimport ArraySymbol, _Graph, Symbol from dwave.optimization.states cimport States __all__ = [ @@ -176,7 +176,7 @@ cdef void _register(object cls, const type_info& typeinfo): _cpp_type_to_python[type_index(typeinfo)] = (cls) -cdef object symbol_from_ptr(Model model, cppNode* node_ptr): +cdef object symbol_from_ptr(_Graph model, cppNode* node_ptr): """Create a Python/Cython symbol from a C++ Node*.""" # If it's null, either after the cast of just as given, then we can't get a symbol from it @@ -236,7 +236,7 @@ cdef class Absolute(ArraySymbol): """ def __init__(self, ArraySymbol x): - cdef Model model = x.model + cdef _Graph model = x.model self.ptr = model._graph.emplace_node[cppAbsoluteNode](x.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -274,7 +274,7 @@ cdef class Add(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppAddNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -308,7 +308,7 @@ cdef class All(ArraySymbol): """ def __init__(self, ArraySymbol array): - cdef Model model = array.model + cdef _Graph model = array.model self.ptr = model._graph.emplace_node[cppAllNode](array.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -337,7 +337,7 @@ cdef class And(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppAndNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -379,7 +379,7 @@ cdef class Any(ArraySymbol): .. versionadded:: 0.4.1 """ def __init__(self, ArraySymbol array): - cdef Model model = array.model + cdef _Graph model = array.model self.ptr = model._graph.emplace_node[cppAnyNode](array.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -400,7 +400,7 @@ _register(Any, typeid(cppAnyNode)) cdef class _ArrayValidation(Symbol): def __init__(self, ArraySymbol array_node): - cdef Model model = array_node.model + cdef _Graph model = array_node.model self.ptr = model._graph.emplace_node[cppArrayValidationNode](array_node.array_ptr) @@ -437,7 +437,7 @@ cdef class AdvancedIndexing(ArraySymbol): """ def __init__(self, ArraySymbol array, *indices): - cdef Model model = array.model + cdef _Graph model = array.model cdef vector[cppAdvancedIndexingNode.array_or_slice] cppindices @@ -508,7 +508,7 @@ cdef class AdvancedIndexing(ArraySymbol): return sym @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): cdef cppNode* ptr indices = [] @@ -566,7 +566,7 @@ cdef class BasicIndexing(ArraySymbol): """ def __init__(self, ArraySymbol array, *indices): - cdef Model model = array.model + cdef _Graph model = array.model cdef vector[cppBasicIndexingNode.slice_or_int] cppindices for index in indices: @@ -607,7 +607,7 @@ cdef class BasicIndexing(ArraySymbol): return sym @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if len(predecessors) != 1: raise ValueError(f"`BasicIndexing` should have exactly one predecessor") @@ -666,7 +666,7 @@ cdef class BinaryVariable(ArraySymbol): >>> type(x) """ - def __init__(self, Model model, shape=None): + def __init__(self, _Graph model, shape=None): # Get an observing pointer to the node cdef vector[Py_ssize_t] vshape = _as_cppshape(tuple() if shape is None else shape) @@ -686,7 +686,7 @@ cdef class BinaryVariable(ArraySymbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a binary symbol from a compressed file. Args: @@ -805,7 +805,7 @@ cdef class Concatenate(ArraySymbol): if len(inputs) < 1: raise TypeError("must have at least one predecessor node") - cdef Model model = inputs[0].model + cdef _Graph model = inputs[0].model cdef vector[cppArrayNode*] cppinputs cdef ArraySymbol array @@ -831,7 +831,7 @@ cdef class Concatenate(ArraySymbol): return m @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if len(predecessors) < 1: raise ValueError("Concatenate must have at least one predecessor") @@ -862,7 +862,7 @@ cdef class Constant(ArraySymbol): >>> type(a) """ - def __init__(self, Model model, array_like): + def __init__(self, _Graph model, array_like): # In the future we won't need to be contiguous, but we do need to be right now array = np.asarray(array_like, dtype=np.double, order="C") @@ -963,7 +963,7 @@ cdef class Constant(ArraySymbol): return constant @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a constant symbol from a compressed file. Args: @@ -1060,7 +1060,7 @@ cdef class DisjointBitSets(Symbol): """ def __init__( - self, Model model, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_sets + self, _Graph model, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_sets ): # Get an observing pointer to the node self.ptr = model._graph.emplace_node[cppDisjointBitSetsNode]( @@ -1081,7 +1081,7 @@ cdef class DisjointBitSets(Symbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a disjoint-sets symbol from a compressed file. Args: @@ -1221,7 +1221,7 @@ cdef class DisjointBitSet(ArraySymbol): if set_index > (parent.ptr.successors().size()): raise ValueError("`DisjointBitSet`s must be created successively") - cdef Model model = parent.model + cdef _Graph model = parent.model if set_index == (parent.ptr.successors().size()): # The DisjointBitSet has not been added to the model yet, so add it self.ptr = model._graph.emplace_node[cppDisjointBitSetNode](parent.ptr) @@ -1245,7 +1245,7 @@ cdef class DisjointBitSet(ArraySymbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a disjoint-set symbol from a compressed file. Args: @@ -1317,7 +1317,7 @@ cdef class DisjointLists(Symbol): """ def __init__( - self, Model model, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_lists + self, _Graph model, Py_ssize_t primary_set_size, Py_ssize_t num_disjoint_lists ): # Get an observing pointer to the node self.ptr = model._graph.emplace_node[cppDisjointListsNode]( @@ -1337,7 +1337,7 @@ cdef class DisjointLists(Symbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a disjoint-lists symbol from a compressed file. Args: @@ -1474,7 +1474,7 @@ cdef class DisjointList(ArraySymbol): if list_index > (parent.ptr.successors().size()): raise ValueError("`DisjointList`s must be created successively") - cdef Model model = parent.model + cdef _Graph model = parent.model if list_index == (parent.ptr.successors().size()): # The DisjointListNode has not been added to the model yet, so add it self.ptr = model._graph.emplace_node[cppDisjointListNode](parent.ptr) @@ -1498,7 +1498,7 @@ cdef class DisjointList(ArraySymbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a disjoint-list symbol from a compressed file. Args: @@ -1575,7 +1575,7 @@ cdef class Divide(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppDivideNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -1613,7 +1613,7 @@ cdef class Equal(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppEqualNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -1645,7 +1645,7 @@ cdef class IntegerVariable(ArraySymbol): >>> type(i) """ - def __init__(self, Model model, shape=None, lower_bound=None, upper_bound=None): + def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None): cdef vector[Py_ssize_t] vshape = _as_cppshape(tuple() if shape is None else shape ) if lower_bound is None and upper_bound is None: @@ -1671,7 +1671,7 @@ cdef class IntegerVariable(ArraySymbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if predecessors: raise ValueError(f"{cls.__name__} cannot have predecessors") @@ -1753,7 +1753,7 @@ cdef class LessEqual(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppLessEqualNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -1785,7 +1785,7 @@ cdef class ListVariable(ArraySymbol): >>> type(l) """ - def __init__(self, Model model, Py_ssize_t n): + def __init__(self, _Graph model, Py_ssize_t n): # Get an observing pointer to the node self.ptr = model._graph.emplace_node[cppListNode](n) @@ -1803,7 +1803,7 @@ cdef class ListVariable(ArraySymbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if predecessors: raise ValueError(f"{cls.__name__} cannot have predecessors") @@ -1863,7 +1863,7 @@ cdef class Logical(ArraySymbol): :func:`~dwave.optimization.mathematical.logical`: equivalent function. """ def __init__(self, ArraySymbol x): - cdef Model model = x.model + cdef _Graph model = x.model self.ptr = model._graph.emplace_node[cppLogicalNode](x.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -1898,7 +1898,7 @@ cdef class Max(ArraySymbol): """ def __init__(self, ArraySymbol node): - cdef Model model = node.model + cdef _Graph model = node.model self.ptr = model._graph.emplace_node[cppMaxNode](node.array_ptr) @@ -1940,7 +1940,7 @@ cdef class Maximum(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppMaximumNode](lhs.array_ptr, rhs.array_ptr) @@ -1976,7 +1976,7 @@ cdef class Min(ArraySymbol): """ def __init__(self, ArraySymbol node): - cdef Model model = node.model + cdef _Graph model = node.model self.ptr = model._graph.emplace_node[cppMinNode](node.array_ptr) @@ -2018,7 +2018,7 @@ cdef class Minimum(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppMinimumNode](lhs.array_ptr, rhs.array_ptr) @@ -2057,7 +2057,7 @@ cdef class Modulus(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppModulusNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -2095,7 +2095,7 @@ cdef class Multiply(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppMultiplyNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -2136,7 +2136,7 @@ cdef class NaryAdd(ArraySymbol): if len(inputs) == 0: raise TypeError("must have at least one predecessor node") - cdef Model model = inputs[0].model + cdef _Graph model = inputs[0].model cdef vector[cppArrayNode*] cppinputs cdef ArraySymbol array @@ -2193,7 +2193,7 @@ cdef class NaryMaximum(ArraySymbol): if len(inputs) == 0: raise TypeError("must have at least one predecessor node") - cdef Model model = inputs[0].model + cdef _Graph model = inputs[0].model cdef vector[cppArrayNode*] cppinputs cdef ArraySymbol array @@ -2243,7 +2243,7 @@ cdef class NaryMinimum(ArraySymbol): if len(inputs) == 0: raise TypeError("must have at least one predecessor node") - cdef Model model = inputs[0].model + cdef _Graph model = inputs[0].model cdef vector[cppArrayNode*] cppinputs cdef ArraySymbol array @@ -2292,7 +2292,7 @@ cdef class NaryMultiply(ArraySymbol): if len(inputs) == 0: raise TypeError("must have at least one predecessor node") - cdef Model model = inputs[0].model + cdef _Graph model = inputs[0].model cdef vector[cppArrayNode*] cppinputs cdef ArraySymbol array @@ -2341,7 +2341,7 @@ cdef class Negative(ArraySymbol): """ def __init__(self, ArraySymbol x): - cdef Model model = x.model + cdef _Graph model = x.model self.ptr = model._graph.emplace_node[cppNegativeNode](x.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -2368,7 +2368,7 @@ cdef class Not(ArraySymbol): :func:`~dwave.optimization.mathematical.logical_not`: equivalent function. """ def __init__(self, ArraySymbol x): - cdef Model model = x.model + cdef _Graph model = x.model self.ptr = model._graph.emplace_node[cppNotNode](x.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -2398,7 +2398,7 @@ cdef class Or(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppOrNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -2433,7 +2433,7 @@ cdef class PartialSum(ArraySymbol): """ def __init__(self, ArraySymbol array, int axis): - cdef Model model = array.model + cdef _Graph model = array.model self.ptr = model._graph.emplace_node[cppPartialSumNode](array.array_ptr, axis) self.initialize_arraynode(model, self.ptr) @@ -2452,7 +2452,7 @@ cdef class PartialSum(ArraySymbol): return ps @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if len(predecessors) != 1: raise ValueError("PartialSum must have exactly one predecessor") @@ -2522,7 +2522,7 @@ cdef class Prod(ArraySymbol): """ def __init__(self, ArraySymbol node): - cdef Model model = node.model + cdef _Graph model = node.model self.ptr = model._graph.emplace_node[cppProdNode](node.array_ptr) @@ -2707,7 +2707,7 @@ cdef class QuadraticModel(ArraySymbol): return self.ptr.get_quadratic_model().get_quadratic(u, v) @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): """Construct a QuadraticModel from a zipfile.""" if len(predecessors) != 1: raise ValueError("Reshape must have exactly one predecessor") @@ -2788,7 +2788,7 @@ cdef class Reshape(ArraySymbol): """ def __init__(self, ArraySymbol node, shape): - cdef Model model = node.model + cdef _Graph model = node.model self.ptr = model._graph.emplace_node[cppReshapeNode]( node.array_ptr, @@ -2809,7 +2809,7 @@ cdef class Reshape(ArraySymbol): return m @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if len(predecessors) != 1: raise ValueError("Reshape must have exactly one predecessor") @@ -2845,7 +2845,7 @@ cdef class SetVariable(ArraySymbol): >>> type(s) """ - def __init__(self, Model model, Py_ssize_t n, Py_ssize_t min_size, Py_ssize_t max_size): + def __init__(self, _Graph model, Py_ssize_t n, Py_ssize_t min_size, Py_ssize_t max_size): self.ptr = model._graph.emplace_node[cppSetNode](n, min_size, max_size) self.initialize_arraynode(model, self.ptr) @@ -2861,7 +2861,7 @@ cdef class SetVariable(ArraySymbol): return x @classmethod - def _from_zipfile(cls, zf, directory, Model model, predecessors): + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): if predecessors: raise ValueError(f"{cls.__name__} cannot have predecessors") @@ -2924,7 +2924,7 @@ _register(SetVariable, typeid(cppSetNode)) cdef class Size(ArraySymbol): def __init__(self, ArraySymbol array): - cdef Model model = array.model + cdef _Graph model = array.model self.ptr = model._graph.emplace_node[cppSizeNode](array.array_ptr) self.initialize_arraynode(array.model, self.ptr) @@ -2961,7 +2961,7 @@ cdef class Square(ArraySymbol): """ def __init__(self, ArraySymbol x): - cdef Model model = x.model + cdef _Graph model = x.model self.ptr = model._graph.emplace_node[cppSquareNode](x.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -2996,7 +2996,7 @@ cdef class SquareRoot(ArraySymbol): """ def __init__(self, ArraySymbol x): - cdef Model model = x.model + cdef _Graph model = x.model self.ptr = model._graph.emplace_node[cppSquareRootNode](x.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -3035,7 +3035,7 @@ cdef class Subtract(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppSubtractNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -3070,7 +3070,7 @@ cdef class Sum(ArraySymbol): """ def __init__(self, ArraySymbol array): - cdef Model model = array.model + cdef _Graph model = array.model self.ptr = model._graph.emplace_node[cppSumNode](array.array_ptr) self.initialize_arraynode(model, self.ptr) @@ -3096,7 +3096,7 @@ cdef class Where(ArraySymbol): :func:`~dwave.optimization.mathematical.where`: equivalent function. """ def __init__(self, ArraySymbol condition, ArraySymbol x, ArraySymbol y): - cdef Model model = condition.model + cdef _Graph model = condition.model if condition.model is not x.model: raise ValueError("condition and x do not share the same underlying model") @@ -3134,7 +3134,7 @@ cdef class Xor(ArraySymbol): if lhs.model is not rhs.model: raise ValueError("lhs and rhs do not share the same underlying model") - cdef Model model = lhs.model + cdef _Graph model = lhs.model self.ptr = model._graph.emplace_node[cppXorNode](lhs.array_ptr, rhs.array_ptr) self.initialize_arraynode(model, self.ptr) diff --git a/meson.build b/meson.build index 8037082b..dbeeeee1 100644 --- a/meson.build +++ b/meson.build @@ -65,8 +65,8 @@ else endif py.extension_module( - 'model', - 'dwave/optimization/model.pyx', + '_model', + 'dwave/optimization/_model.pyx', dependencies: libdwave_optimization, gnu_symbol_visibility: 'default', install: true, diff --git a/releasenotes/notes/_Graph-e85b234643bb01a7.yaml b/releasenotes/notes/_Graph-e85b234643bb01a7.yaml new file mode 100644 index 00000000..1f0b1eb6 --- /dev/null +++ b/releasenotes/notes/_Graph-e85b234643bb01a7.yaml @@ -0,0 +1,13 @@ +--- +features: + - | + Add a new Cython extension type, ``_Graph``. The ``_Graph`` class is + responsible for managing a C++ ``dwave::optimization::Graph``. + Also make ``Model`` a Python class that inherits from ``_Graph``, rather than + a Cython extension type. +upgrade: + - | + It is no longer possible to ``from dwave.optimization.model cimport Model``. + Instead, importers should ``from dwave.optimization.model cimport _Graph``. + A ``_Graph`` has most of the functionality that a ``Model`` did, although + most of the symbol construction methods have been kept on ``Model``. diff --git a/tests/test_model.py b/tests/test_model.py index 9a8df008..09db9c87 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -118,7 +118,7 @@ def __init__(self, array): def __getitem__(self, index): if not isinstance(index, tuple): return self[(index,)] - i0, i1 = dwave.optimization.model._split_indices(index) + i0, i1 = dwave.optimization._model._split_indices(index) np.testing.assert_array_equal(self.array[index], self.array[i0][i1]) def test_split_indices(self):