From 36d39e53cab8ba646286b1325706287d4a254a9e Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 2 Dec 2024 13:16:31 -0800 Subject: [PATCH] Move States into a new states.pyx --- docs/reference/models.rst | 2 +- dwave/optimization/model.pxd | 36 +- dwave/optimization/model.pyi | 27 -- dwave/optimization/model.pyx | 335 ++---------------- dwave/optimization/states.pxd | 45 +++ dwave/optimization/states.pyi | 45 +++ dwave/optimization/states.pyx | 331 +++++++++++++++++ dwave/optimization/symbols.pyx | 13 +- meson.build | 11 + .../breakout-states-8dcfec6ceb254cb6.yaml | 5 + tests/test_model.py | 161 --------- tests/test_states.py | 182 ++++++++++ 12 files changed, 649 insertions(+), 544 deletions(-) create mode 100644 dwave/optimization/states.pxd create mode 100644 dwave/optimization/states.pyi create mode 100644 dwave/optimization/states.pyx create mode 100644 releasenotes/notes/breakout-states-8dcfec6ceb254cb6.yaml create mode 100644 tests/test_states.py diff --git a/docs/reference/models.rst b/docs/reference/models.rst index a6598cdb..168dace7 100644 --- a/docs/reference/models.rst +++ b/docs/reference/models.rst @@ -76,7 +76,7 @@ Model Methods States Class ------------ -.. currentmodule:: dwave.optimization.model +.. currentmodule:: dwave.optimization.states .. autoclass:: States diff --git a/dwave/optimization/model.pxd b/dwave/optimization/model.pxd index 172fe1aa..fd501502 100644 --- a/dwave/optimization/model.pxd +++ b/dwave/optimization/model.pxd @@ -62,7 +62,7 @@ cdef class Model: Objective = -4.0 """ - cdef readonly States states + cdef readonly object states """States of the model. :ref:`States ` represent assignments of values @@ -83,35 +83,6 @@ cdef class Model: cdef object _data_sources -cdef class States: - """The states/solutions of the model.""" - - cdef void attach_states(self, vector[cppState]) noexcept - cdef vector[cppState] detach_states(self) - cpdef resolve(self) - cpdef Py_ssize_t size(self) except -1 - - cdef Model _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 - # makes sure that the Model is promptly garbage collected - cdef readonly object _model_ref - - # The state(s) of the model kept as a ragged vector-of-vectors (each - # cppState is a vector). - # Accessors should check the length of the state when accessing! - cdef vector[cppState] _states - - # The number of views into the states that exist. The model cannot - # be unlocked while there are unsafe views - cdef Py_ssize_t _view_count - - # Object that contains or will contain the information needed to construct states - cdef readonly object _future - cdef readonly object _result_hook - - cdef class Symbol: # Inheriting nodes must call this method from their __init__() cdef void initialize_node(self, Model model, cppNode* node_ptr) noexcept @@ -151,8 +122,3 @@ cdef class ArraySymbol(Symbol): # 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 - - -cdef class StateView: - cdef readonly Py_ssize_t index # which state we're accessing - cdef readonly ArraySymbol symbol diff --git a/dwave/optimization/model.pyi b/dwave/optimization/model.pyi index 27197ffd..00a5846f 100644 --- a/dwave/optimization/model.pyi +++ b/dwave/optimization/model.pyi @@ -112,33 +112,6 @@ class Model: def unlock(self): ... -class States: - def __init__(self, model: Model): ... - def __len__(self) -> int: ... - def clear(self): ... - - def from_file( - self, - file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], - *, - replace: bool = True, - check_header: bool = True, - ) -> Model: ... - - def from_future(self, future: object, result_hook: collections.abc.Callable): ... - def initialize(self): ... - - def into_file( - self, - file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], - ): ... - - def resize(self, n: int): ... - def resolve(self): ... - def size(self) -> int: ... - def to_file(self) -> typing.BinaryIO: ... - - class Symbol: def __init__(self, *args, **kwargs) -> typing.NoReturn: ... def equals(self, other: Symbol) -> bool: ... diff --git a/dwave/optimization/model.pyx b/dwave/optimization/model.pyx index 43e666a3..2dca7d26 100644 --- a/dwave/optimization/model.pyx +++ b/dwave/optimization/model.pyx @@ -32,7 +32,6 @@ import numbers import operator import struct import tempfile -import weakref import zipfile import numpy as np @@ -46,6 +45,8 @@ from libcpp.vector cimport vector from dwave.optimization.libcpp.array cimport Array as cppArray from dwave.optimization.libcpp.graph cimport DecisionNode as cppDecisionNode +from dwave.optimization.states cimport States +from dwave.optimization.states import StateView from dwave.optimization.symbols cimport symbol_from_ptr @@ -1023,279 +1024,16 @@ 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(self.states.size()): + for i in range(states.size()): # this might actually increase the size of the states in some # cases, but that's fine - self.states._states[i].resize(self.num_decisions()) - - -cdef class States: - r"""States of a symbol in a model. - - States represent assignments of values to a symbol's elements. For - example, an :meth:`~Model.integer` symbol of size :math:`1 \times 5` - might have state ``[3, 8, 0, 12, 8]``, representing one assignment - of values to the symbol. - - Examples: - This example creates a :class:`~dwave.optimization.generators.knapsack` - model and manipulates its states to test that it behaves as expected. - - First, create a model. - - >>> from dwave.optimization import Model - ... - >>> model = Model() - >>> # Add constants - >>> weights = model.constant([10, 20, 5, 15]) - >>> values = model.constant([-5, -7, -2, -9]) - >>> capacity = model.constant(30) - >>> # Add the decision variable - >>> items = model.set(4) - >>> # add the capacity constraint - >>> model.add_constraint(weights[items].sum() <= capacity) # doctest: +ELLIPSIS - - >>> # Set the objective - >>> model.minimize(values[items].sum()) - - Lock the model to prevent changes to directed acyclic graph. At any - time, you can verify the locked state, which is demonstrated here. - - >>> with model.lock(): - ... model.is_locked() - True - - Set a couple of states on the decision variable and verify that the - model generates the expected values for the objective. - - >>> model.states.resize(2) - >>> items.set_state(0, [0, 1]) - >>> items.set_state(1, [0, 2, 3]) - >>> with model.lock(): - ... print(model.objective.state(0) > model.objective.state(1)) - True - - You can clear the states you set. - - >>> model.states.clear() - >>> model.states.size() - 0 - """ - def __init__(self, Model model): - self._model_ref = weakref.ref(model) - - def __len__(self): - """The number of model states.""" - return self.size() - - cdef void attach_states(self, vector[cppState] states) noexcept: - """Attach the given states. - - Note: - Currently replaces any current states with the given states. - - This method does not check whether the states are locked - or that the states are valid. - - Args: - states: States to be attached. - """ - self._future = None - self._result_hook = None - self._states.swap(states) - - def clear(self): - """Clear any saved states. - - Clears any memory allocated to the states. - - Examples: - This example clears a state set on an integer decision symbol. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer(2) - >>> model.states.resize(3) - >>> i.set_state(0, [3, 5]) - >>> print(i.state(0)) - [3. 5.] - >>> model.states.clear() - """ - self.detach_states() - - cdef vector[cppState] detach_states(self): - """Move the current C++ states into a returned vector. - - Leaves the model's states empty. - - Note: - This method does not check whether the states are locked. - - Returns: - States of the model prior to execution. - """ - self.resolve() - # move should impliclty leave the states in a valid state, but - # just to be super explicit we swap with an empty vector first - cdef vector[cppState] states - self._states.swap(states) - return move(states) - - def from_file(self, file, *, bool replace = True, check_header = True): - """Construct states from the given file. - - Args: - file: - File pointer to a readable, seekable file-like object encoding - the states. Strings are interpreted as a file name. - replace: - If ``True``, any held states are replaced with those from the file. - If ``False``, the states are appended. - check_header: - Set to ``False`` to skip file-header check. - - Returns: - A model. - """ - self.resolve() - - if not replace: - raise NotImplementedError("appending states is not (yet) implemented") - - # 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) - - # Check that the model is compatible - for n0, n1 in zip(model.iter_symbols(), self._model().iter_symbols()): - # todo: replace with proper node quality testing once we have it - if not isinstance(n0, type(n1)): - raise ValueError("cannot load states into a model with mismatched decisions") - - self.attach_states(move(model.states.detach_states())) - - def from_future(self, future, result_hook): - """Populate the states from the result of a future computation. - - A :doc:`Future ` object is - returned by the solver to which your problem model is submitted. This - enables asynchronous problem submission. - - Args: - future: ``Future`` object. - - result_hook: Method executed to retrieve the Future. - """ - self.resize(0) # always clears self first - - self._future = future - self._result_hook = result_hook - - def initialize(self): - """Initialize any uninitialized states.""" - self.resolve() - - cdef Model model = self._model() - - if not model.is_locked(): - raise ValueError("Cannot initialize states of an unlocked model") - for i in range(self._states.size()): - self._states[i].resize(model.num_nodes()) - model._graph.initialize_state(self._states[i]) - - def into_file(self, file): - """Serialize the states into an existing file. - - Args: - file: - File pointer to an existing writeable, seekable file-like - object encoding a model. Strings are interpreted as a file - name. - - TODO: describe the format - """ - self.resolve() - return self._model().into_file(file, only_decision=True, max_num_states=self.size()) - - - cdef Model _model(self): - """Get a ref-counted Model object.""" - cdef Model m = self._model_ref() - if m is None: - raise ReferenceError("accessing the states of a garbage collected model") - return m - - def resize(self, Py_ssize_t n): - """Resize the number of states. - - If ``n`` is smaller than the current :meth:`.size()`, - states are reduced to the first ``n`` states by removing - those beyond. If ``n`` is greater than the current - :meth:`.size()`, new uninitialized states are added - as needed to reach a size of ``n``. - - Resizing to 0 is not guaranteed to clear the memory allocated to - states. - - Args: - n: Required number of states. - - Examples: - This example adds three uninitialized states to a model. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> i = model.integer(2) - >>> model.states.resize(3) - """ - self.resolve() - - if n < 0: - raise ValueError("n must be a non-negative integer") - - self._states.resize(n) - - cpdef resolve(self): - """Block until states are retrieved from any pending future computations. - - A :doc:`Future ` object is - returned by the solver to which your problem model is submitted. This - enables asynchronous problem submission. - """ - if self._future is not None: - # The existance of _future means that anything we do to the - # state will block. So we remove it before calling the hook. - future = self._future - self._future = None - result_hook = self._result_hook - self._result_hook = None - - result_hook(self._model(), future) - - cpdef Py_ssize_t size(self) except -1: - """Number of model states. - - Examples: - This example adds three uninitialized states to a model and - verifies the number of model states. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> model.states.resize(3) - >>> model.states.size() - 3 - """ - self.resolve() - return self._states.size() - - def to_file(self): - """Serialize the states to a new file-like object.""" - self.resolve() - return self._model().to_file(only_decision=True, max_num_states=self.size()) + states._states[i].resize(self.num_decisions()) cdef class Symbol: @@ -1405,23 +1143,25 @@ cdef class Symbol: raise TypeError("the state of an intermediate variable cannot be accessed without " "locking the model first. See model.lock().") - cdef Py_ssize_t num_states = self.model.states.size() + cdef States states = self.model.states # for Cython access + + states.resolve() + + cdef Py_ssize_t num_states = states.size() if not -num_states <= index < num_states: raise ValueError(f"index out of range: {index}") if index < 0: # allow negative indexing index += num_states - self.model.states.resolve() - # States are extended lazily, so if the state isn't yet long enough then this # node's state has not been initialized - if (self.model.states._states[index].size()) <= self.node_ptr.topological_index(): + if (states._states[index].size()) <= self.node_ptr.topological_index(): return False # Check that the state pointer is not null # We need to explicitly cast to evoke unique_ptr's operator bool - return (self.model.states._states[index][self.node_ptr.topological_index()]) + return (states._states[index][self.node_ptr.topological_index()]) cpdef uintptr_t id(self) noexcept: """Return the "identity" of the underlying node. @@ -1615,20 +1355,22 @@ cdef class Symbol: state 0: [0. 1. 2. 3. 4.] and [] state 1: [3. 4.] and [0. 1. 2.] """ - if not 0 <= index < self.model.states.size(): + cdef States states = self.model.states + + states.resolve() + + if not 0 <= index < states.size(): raise ValueError(f"index out of range: {index}") if self.node_ptr.topological_index() < 0: # unsorted nodes don't have a state to reset return - self.model.states.resolve() - # make sure the state vector at least contains self - if (self.model.states._states[index].size()) <= self.node_ptr.topological_index(): - self.model.states._states[index].resize(self.node_ptr.topological_index() + 1) + if (states._states[index].size()) <= self.node_ptr.topological_index(): + states._states[index].resize(self.node_ptr.topological_index() + 1) - self.model._graph.recursive_reset(self.model.states._states[index], self.node_ptr) + self.model._graph.recursive_reset(states._states[index], self.node_ptr) def shares_memory(self, other): """Determine if two symbols share memory. @@ -2165,38 +1907,3 @@ cdef class ArraySymbol(Symbol): return dwave.optimization.symbols.PartialSum(self, axis) return dwave.optimization.symbols.Sum(self) - - -cdef class StateView: - def __init__(self, ArraySymbol symbol, Py_ssize_t index): - self.symbol = symbol - self.index = index - - # we're assuming this object is being created because we want to access - # the state, so let's go ahead and create the state if it's not already - # there - symbol.model.states.resolve() - symbol.model._graph.recursive_initialize(symbol.model.states._states[index], symbol.node_ptr) - - def __getbuffer__(self, Py_buffer *buffer, int flags): - # todo: inspect/respect/test flags - self.symbol.model.states.resolve() - - cdef cppArray* ptr = self.symbol.array_ptr - - buffer.buf = (ptr.buff(self.symbol.model.states._states.at(self.index))) - buffer.format = (ptr.format().c_str()) - buffer.internal = NULL - buffer.itemsize = ptr.itemsize() - buffer.len = ptr.len(self.symbol.model.states._states.at(self.index)) - buffer.ndim = ptr.ndim() - buffer.obj = self - buffer.readonly = 1 # todo: consider loosening this requirement - buffer.shape = (ptr.shape(self.symbol.model.states._states.at(self.index)).data()) - buffer.strides = (ptr.strides().data()) - buffer.suboffsets = NULL - - self.symbol.model.states._view_count += 1 - - def __releasebuffer__(self, Py_buffer *buffer): - self.symbol.model.states._view_count -= 1 diff --git a/dwave/optimization/states.pxd b/dwave/optimization/states.pxd new file mode 100644 index 00000000..7fe5b5bc --- /dev/null +++ b/dwave/optimization/states.pxd @@ -0,0 +1,45 @@ +# Copyright 2024 D-Wave +# +# 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 libcpp.vector cimport vector + +from dwave.optimization.libcpp.state cimport State as cppState +from dwave.optimization.model cimport Model + + +cdef class States: + cdef void attach_states(self, vector[cppState]) noexcept + cdef vector[cppState] detach_states(self) + cpdef resolve(self) + cpdef Py_ssize_t size(self) except -1 + + cdef Model _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 + # makes sure that the Model is promptly garbage collected + cdef readonly object _model_ref + + # The state(s) of the model kept as a ragged vector-of-vectors (each + # cppState is a vector). + # Accessors should check the length of the state when accessing! + cdef vector[cppState] _states + + # The number of views into the states that exist. The model cannot + # be unlocked while there are unsafe views + cdef public Py_ssize_t _view_count + + # Object that contains or will contain the information needed to construct states + cdef readonly object _future + cdef readonly object _result_hook diff --git a/dwave/optimization/states.pyi b/dwave/optimization/states.pyi new file mode 100644 index 00000000..9381953e --- /dev/null +++ b/dwave/optimization/states.pyi @@ -0,0 +1,45 @@ +# Copyright 2024 D-Wave +# +# 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. + +import collections.abc +import typing + +from dwave.optimization.model import Model + + +class States: + def __init__(self, model: Model): ... + def __len__(self) -> int: ... + def clear(self): ... + + def from_file( + self, + file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], + *, + replace: bool = True, + check_header: bool = True, + ) -> Model: ... + + def from_future(self, future: object, result_hook: collections.abc.Callable): ... + def initialize(self): ... + + def into_file( + self, + file: typing.Union[typing.BinaryIO, collections.abc.ByteString, str], + ): ... + + def resize(self, n: int): ... + def resolve(self): ... + def size(self) -> int: ... + def to_file(self) -> typing.BinaryIO: ... diff --git a/dwave/optimization/states.pyx b/dwave/optimization/states.pyx new file mode 100644 index 00000000..5ca0f6f1 --- /dev/null +++ b/dwave/optimization/states.pyx @@ -0,0 +1,331 @@ +# Copyright 2024 D-Wave +# +# 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. + +import weakref + +from libcpp.utility cimport move + +from dwave.optimization.libcpp.array cimport Array as cppArray +from dwave.optimization.model cimport ArraySymbol + + +__all__ = ["States"] + + +cdef class States: + r"""States of a symbol in a model. + + States represent assignments of values to a symbol's elements. For + example, an :meth:`~Model.integer` symbol of size :math:`1 \times 5` + might have state ``[3, 8, 0, 12, 8]``, representing one assignment + of values to the symbol. + + Examples: + This example creates a :class:`~dwave.optimization.generators.knapsack` + model and manipulates its states to test that it behaves as expected. + + First, create a model. + + >>> from dwave.optimization import Model + ... + >>> model = Model() + >>> # Add constants + >>> weights = model.constant([10, 20, 5, 15]) + >>> values = model.constant([-5, -7, -2, -9]) + >>> capacity = model.constant(30) + >>> # Add the decision variable + >>> items = model.set(4) + >>> # add the capacity constraint + >>> model.add_constraint(weights[items].sum() <= capacity) # doctest: +ELLIPSIS + + >>> # Set the objective + >>> model.minimize(values[items].sum()) + + Lock the model to prevent changes to directed acyclic graph. At any + time, you can verify the locked state, which is demonstrated here. + + >>> with model.lock(): + ... model.is_locked() + True + + Set a couple of states on the decision variable and verify that the + model generates the expected values for the objective. + + >>> model.states.resize(2) + >>> items.set_state(0, [0, 1]) + >>> items.set_state(1, [0, 2, 3]) + >>> with model.lock(): + ... print(model.objective.state(0) > model.objective.state(1)) + True + + You can clear the states you set. + + >>> model.states.clear() + >>> model.states.size() + 0 + """ + def __init__(self, Model model): + self._model_ref = weakref.ref(model) + + def __len__(self): + """The number of model states.""" + return self.size() + + cdef void attach_states(self, vector[cppState] states) noexcept: + """Attach the given states. + + Note: + Currently replaces any current states with the given states. + + This method does not check whether the states are locked + or that the states are valid. + + Args: + states: States to be attached. + """ + self._future = None + self._result_hook = None + self._states.swap(states) + + def clear(self): + """Clear any saved states. + + Clears any memory allocated to the states. + + Examples: + This example clears a state set on an integer decision symbol. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer(2) + >>> model.states.resize(3) + >>> i.set_state(0, [3, 5]) + >>> print(i.state(0)) + [3. 5.] + >>> model.states.clear() + """ + self.detach_states() + + cdef vector[cppState] detach_states(self): + """Move the current C++ states into a returned vector. + + Leaves the model's states empty. + + Note: + This method does not check whether the states are locked. + + Returns: + States of the model prior to execution. + """ + self.resolve() + # move should impliclty leave the states in a valid state, but + # just to be super explicit we swap with an empty vector first + cdef vector[cppState] states + self._states.swap(states) + return move(states) + + def from_file(self, file, *, replace = True, check_header = True): + """Construct states from the given file. + + Args: + file: + File pointer to a readable, seekable file-like object encoding + the states. Strings are interpreted as a file name. + replace: + If ``True``, any held states are replaced with those from the file. + If ``False``, the states are appended. + check_header: + Set to ``False`` to skip file-header check. + + Returns: + A model. + """ + self.resolve() + + if not replace: + raise NotImplementedError("appending states is not (yet) implemented") + + # 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 States states = model.states + + # Check that the model is compatible + for n0, n1 in zip(model.iter_symbols(), self._model().iter_symbols()): + # todo: replace with proper node quality testing once we have it + if not isinstance(n0, type(n1)): + raise ValueError("cannot load states into a model with mismatched decisions") + + self.attach_states(move(states.detach_states())) + + def from_future(self, future, result_hook): + """Populate the states from the result of a future computation. + + A :doc:`Future ` object is + returned by the solver to which your problem model is submitted. This + enables asynchronous problem submission. + + Args: + future: ``Future`` object. + + result_hook: Method executed to retrieve the Future. + """ + self.resize(0) # always clears self first + + self._future = future + self._result_hook = result_hook + + def initialize(self): + """Initialize any uninitialized states.""" + self.resolve() + + cdef Model model = self._model() + + if not model.is_locked(): + raise ValueError("Cannot initialize states of an unlocked model") + for i in range(self._states.size()): + self._states[i].resize(model.num_nodes()) + model._graph.initialize_state(self._states[i]) + + def into_file(self, file): + """Serialize the states into an existing file. + + Args: + file: + File pointer to an existing writeable, seekable file-like + object encoding a model. Strings are interpreted as a file + name. + + TODO: describe the format + """ + self.resolve() + return self._model().into_file(file, only_decision=True, max_num_states=self.size()) + + + cdef Model _model(self): + """Get a ref-counted Model object.""" + cdef Model m = self._model_ref() + if m is None: + raise ReferenceError("accessing the states of a garbage collected model") + return m + + def resize(self, Py_ssize_t n): + """Resize the number of states. + + If ``n`` is smaller than the current :meth:`.size()`, + states are reduced to the first ``n`` states by removing + those beyond. If ``n`` is greater than the current + :meth:`.size()`, new uninitialized states are added + as needed to reach a size of ``n``. + + Resizing to 0 is not guaranteed to clear the memory allocated to + states. + + Args: + n: Required number of states. + + Examples: + This example adds three uninitialized states to a model. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> i = model.integer(2) + >>> model.states.resize(3) + """ + self.resolve() + + if n < 0: + raise ValueError("n must be a non-negative integer") + + self._states.resize(n) + + cpdef resolve(self): + """Block until states are retrieved from any pending future computations. + + A :doc:`Future ` object is + returned by the solver to which your problem model is submitted. This + enables asynchronous problem submission. + """ + if self._future is not None: + # The existance of _future means that anything we do to the + # state will block. So we remove it before calling the hook. + future = self._future + self._future = None + result_hook = self._result_hook + self._result_hook = None + + result_hook(self._model(), future) + + cpdef Py_ssize_t size(self) except -1: + """Number of model states. + + Examples: + This example adds three uninitialized states to a model and + verifies the number of model states. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> model.states.resize(3) + >>> model.states.size() + 3 + """ + self.resolve() + return self._states.size() + + def to_file(self): + """Serialize the states to a new file-like object.""" + self.resolve() + return self._model().to_file(only_decision=True, max_num_states=self.size()) + + +cdef class StateView: + def __init__(self, ArraySymbol symbol, Py_ssize_t index): + self.symbol = symbol + self.index = index + + # we're assuming this object is being created because we want to access + # the state, so let's go ahead and create the state if it's not already + # there + cdef States states = symbol.model.states # for Cython access + + states.resolve() + symbol.model._graph.recursive_initialize(states._states.at(index), symbol.node_ptr) + + def __getbuffer__(self, Py_buffer *buffer, int flags): + # todo: inspect/respect/test flags + self.symbol.model.states.resolve() + + cdef States states = self.symbol.model.states # for Cython access + + cdef cppArray* ptr = self.symbol.array_ptr + + buffer.buf = (ptr.buff(states._states.at(self.index))) + buffer.format = (ptr.format().c_str()) + buffer.internal = NULL + buffer.itemsize = ptr.itemsize() + buffer.len = ptr.len(states._states.at(self.index)) + buffer.ndim = ptr.ndim() + buffer.obj = self + buffer.readonly = 1 # todo: consider loosening this requirement + buffer.shape = (ptr.shape(states._states.at(self.index)).data()) + buffer.strides = (ptr.strides().data()) + buffer.suboffsets = NULL + + states._view_count += 1 + + def __releasebuffer__(self, Py_buffer *buffer): + self.symbol.model.states._view_count -= 1 + + cdef readonly Py_ssize_t index # which state we're accessing + cdef readonly ArraySymbol symbol diff --git a/dwave/optimization/symbols.pyx b/dwave/optimization/symbols.pyx index 4ceebff5..ae71eb97 100644 --- a/dwave/optimization/symbols.pyx +++ b/dwave/optimization/symbols.pyx @@ -96,6 +96,7 @@ from dwave.optimization.libcpp.nodes cimport ( XorNode as cppXorNode, ) from dwave.optimization.model cimport ArraySymbol, Model, Symbol +from dwave.optimization.states cimport States __all__ = [ "Absolute", @@ -775,7 +776,7 @@ cdef class BinaryVariable(ArraySymbol): items.push_back(arr[i]) # The validity of the state is checked in C++ - self.ptr.initialize_state(self.model.states._states[index], move(items)) + self.ptr.initialize_state((self.model.states)._states[index], move(items)) # An observing pointer to the C++ BinaryNode cdef cppBinaryNode* ptr @@ -1168,7 +1169,7 @@ cdef class DisjointBitSets(Symbol): sets[i].push_back(arr[i, j]) # The validity of the state is checked in C++ - self.ptr.initialize_state(self.model.states._states[index], move(sets)) + self.ptr.initialize_state((self.model.states)._states[index], move(sets)) def _state_from_zipfile(self, zf, directory, Py_ssize_t state_index): arrays = [] @@ -1421,7 +1422,7 @@ cdef class DisjointLists(Symbol): items[i].push_back(arr[j]) # The validity of the state is checked in C++ - self.ptr.initialize_state(self.model.states._states[index], move(items)) + self.ptr.initialize_state((self.model.states)._states[index], move(items)) def _state_from_zipfile(self, zf, directory, Py_ssize_t state_index): arrays = [] @@ -1682,7 +1683,7 @@ cdef class IntegerVariable(ArraySymbol): items.push_back(arr[i]) # The validity of the state is checked in C++ - self.ptr.initialize_state(self.model.states._states[index], move(items)) + self.ptr.initialize_state((self.model.states)._states[index], move(items)) def upper_bound(self): """The highest value allowed for the integer symbol.""" @@ -1807,7 +1808,7 @@ cdef class ListVariable(ArraySymbol): items.push_back(arr[i]) # The validity of the state is checked in C++ - self.ptr.initialize_state(self.model.states._states[index], move(items)) + self.ptr.initialize_state((self.model.states)._states[index], move(items)) # An observing pointer to the C++ ListNode cdef cppListNode* ptr @@ -2873,7 +2874,7 @@ cdef class SetVariable(ArraySymbol): items.push_back(arr[i]) # The validity of the state is checked in C++ - self.ptr.initialize_state(self.model.states._states[index], move(items)) + self.ptr.initialize_state((self.model.states)._states[index], move(items)) # Observing pointer to the node cdef cppSetNode* ptr diff --git a/meson.build b/meson.build index 97d295f8..8037082b 100644 --- a/meson.build +++ b/meson.build @@ -75,6 +75,17 @@ py.extension_module( subdir: 'dwave/optimization/', ) +py.extension_module( + 'states', + 'dwave/optimization/states.pyx', + dependencies: libdwave_optimization, + gnu_symbol_visibility: 'default', + install: true, + install_rpath: '$ORIGIN', + override_options : ['cython_language=cpp'], + subdir: 'dwave/optimization/', +) + py.extension_module( 'symbols', 'dwave/optimization/symbols.pyx', diff --git a/releasenotes/notes/breakout-states-8dcfec6ceb254cb6.yaml b/releasenotes/notes/breakout-states-8dcfec6ceb254cb6.yaml new file mode 100644 index 00000000..d51f0087 --- /dev/null +++ b/releasenotes/notes/breakout-states-8dcfec6ceb254cb6.yaml @@ -0,0 +1,5 @@ +--- +features: + - Add ``dwave.optimization.states`` submodule holding the implementation of the ``States`` class. +upgrade: + - Move the implementation of the ``States`` class to ``dwave.optimization.states``. diff --git a/tests/test_model.py b/tests/test_model.py index bfa8353e..9a8df008 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -12,12 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import concurrent.futures -import io import operator import os.path import tempfile -import threading import unittest import numpy as np @@ -517,164 +514,6 @@ def test_to_networkx_objective_and_constraints(self): self.assertIn("constraint(s)", G.nodes) -class TestStates(unittest.TestCase): - def test_clear(self): - model = Model() - model.states.resize(100) - self.assertEqual(model.states.size(), 100) - model.states.clear() - self.assertEqual(model.states.size(), 0) - - def test_from_future(self): - model = Model() - x = model.list(10) - model.minimize(x.sum()) - - model.states.resize(5) - - fp = io.BytesIO() - model.lock() - model.states.into_file(fp) - fp.seek(0) # into file doesn't return to the beginning - model.states.resize(0) - - future = concurrent.futures.Future() - - def hook(model, future): - model.states.from_file(future.result()) - - model.states.from_future(future, hook) - - future.set_result(fp) - - x.state(0) - - def test_resolve(self): - model = Model() - x = model.list(10) - model.minimize(x.sum()) - - model.states.resize(5) - - fp = io.BytesIO() - model.lock() - model.states.into_file(fp) - fp.seek(0) # into file doesn't return to the beginning - model.states.resize(0) - - executor = concurrent.futures.ThreadPoolExecutor() - future = concurrent.futures.Future() - resolved = threading.Event() - - def hook(model, future): - model.states.from_file(future.result()) - - model.states.from_future(future, hook) - - def worker(): - x.state(0) - resolved.set() - - executor.submit(worker) - - self.assertFalse(resolved.wait(timeout=0.1)) - - future.set_result(fp) - - executor.shutdown(wait=True) - - self.assertTrue(resolved.is_set()) - - def test_set_state_with_successors(self): - model = Model() - b = model.integer() - c = model.constant(2) - model.minimize(b + c) - model.states.resize(1) - - b.set_state(0, 1) - self.assertEqual(b.state(0), 1) - - # can set state when locked - with model.lock(): - b.set_state(0, 2) - self.assertEqual(b.state(0), 2) - - b.set_state(0, 3) - self.assertEqual(b.state(0), 3) - - def test_init(self): - model = Model() - - self.assertTrue(hasattr(model, "states")) - self.assertIsInstance(model.states, dwave.optimization.model.States) - - # by default there are no states - self.assertEqual(len(model.states), 0) - self.assertEqual(model.states.size(), 0) - - def test_resize(self): - model = Model() - - # cannot resize to a negative number - with self.assertRaises(ValueError): - model.states.resize(-1) - - # we can resize if the model and states are unlocked - model.states.resize(5) - self.assertEqual(model.states.size(), 5) - - # we can resize the states if the model is locked and the states are - # unlocked - model.lock() - model.states.resize(10) - self.assertEqual(model.states.size(), 10) - - def test_serialization(self): - model = Model() - x = model.list(10) - model.states.resize(3) - x.set_state(0, list(reversed(range(10)))) - # don't set state 1 - x.state(2) # default initialize - - # Get another model with the same shape. This won't work in general - # unless you're very careful to always insert nodes in the same order - new = Model() - a = new.list(10) - - with model.states.to_file() as f: - new.states.from_file(f) - - self.assertEqual(new.states.size(), 3) - np.testing.assert_array_equal(a.state(0), x.state(0)) - self.assertFalse(a.has_state(1)) - np.testing.assert_array_equal(a.state(2), x.state(2)) - - def test_serialization_bad(self): - model = Model() - x = model.list(10) - model.states.resize(1) - x.set_state(0, list(reversed(range(10)))) - - with self.subTest("different node class"): - new = Model() - new.constant(10) - - with model.states.to_file() as f: - with self.assertRaises(ValueError): - new.states.from_file(f) - - # todo: uncomment once we have proper node equality testing - # with self.subTest("different node shape"): - # new = Model() - # new.list(9) - - # with model.states.to_file() as f: - # with self.assertRaises(ValueError): - # new.states.from_file(f) - - class TestSymbol(unittest.TestCase): def test_abstract(self): from dwave.optimization.model import Symbol diff --git a/tests/test_states.py b/tests/test_states.py new file mode 100644 index 00000000..a6659a4d --- /dev/null +++ b/tests/test_states.py @@ -0,0 +1,182 @@ +# Copyright 2024 D-Wave Systems 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. + +import concurrent.futures +import io +import threading +import unittest + +import numpy as np + +import dwave.optimization + +from dwave.optimization import Model + + +class TestStates(unittest.TestCase): + def test_clear(self): + model = Model() + model.states.resize(100) + self.assertEqual(model.states.size(), 100) + model.states.clear() + self.assertEqual(model.states.size(), 0) + + def test_from_future(self): + model = Model() + x = model.list(10) + model.minimize(x.sum()) + + model.states.resize(5) + + fp = io.BytesIO() + model.lock() + model.states.into_file(fp) + fp.seek(0) # into file doesn't return to the beginning + model.states.resize(0) + + future = concurrent.futures.Future() + + def hook(model, future): + model.states.from_file(future.result()) + + model.states.from_future(future, hook) + + future.set_result(fp) + + x.state(0) + + def test_resolve(self): + model = Model() + x = model.list(10) + model.minimize(x.sum()) + + model.states.resize(5) + + fp = io.BytesIO() + model.lock() + model.states.into_file(fp) + fp.seek(0) # into file doesn't return to the beginning + model.states.resize(0) + + executor = concurrent.futures.ThreadPoolExecutor() + future = concurrent.futures.Future() + resolved = threading.Event() + + def hook(model, future): + model.states.from_file(future.result()) + + model.states.from_future(future, hook) + + def worker(): + x.state(0) + resolved.set() + + executor.submit(worker) + + self.assertFalse(resolved.wait(timeout=0.1)) + + future.set_result(fp) + + executor.shutdown(wait=True) + + self.assertTrue(resolved.is_set()) + + def test_set_state_with_successors(self): + model = Model() + b = model.integer() + c = model.constant(2) + model.minimize(b + c) + model.states.resize(1) + + b.set_state(0, 1) + self.assertEqual(b.state(0), 1) + + # can set state when locked + with model.lock(): + b.set_state(0, 2) + self.assertEqual(b.state(0), 2) + + b.set_state(0, 3) + self.assertEqual(b.state(0), 3) + + def test_init(self): + model = Model() + + self.assertTrue(hasattr(model, "states")) + self.assertIsInstance(model.states, dwave.optimization.states.States) + + # by default there are no states + self.assertEqual(len(model.states), 0) + self.assertEqual(model.states.size(), 0) + + def test_resize(self): + model = Model() + + # cannot resize to a negative number + with self.assertRaises(ValueError): + model.states.resize(-1) + + # we can resize if the model and states are unlocked + model.states.resize(5) + self.assertEqual(model.states.size(), 5) + + # we can resize the states if the model is locked and the states are + # unlocked + model.lock() + model.states.resize(10) + self.assertEqual(model.states.size(), 10) + + def test_serialization(self): + model = Model() + x = model.list(10) + model.states.resize(3) + x.set_state(0, list(reversed(range(10)))) + # don't set state 1 + x.state(2) # default initialize + + # Get another model with the same shape. This won't work in general + # unless you're very careful to always insert nodes in the same order + new = Model() + a = new.list(10) + + with model.states.to_file() as f: + new.states.from_file(f) + + self.assertEqual(new.states.size(), 3) + np.testing.assert_array_equal(a.state(0), x.state(0)) + self.assertFalse(a.has_state(1)) + np.testing.assert_array_equal(a.state(2), x.state(2)) + + def test_serialization_bad(self): + model = Model() + x = model.list(10) + model.states.resize(1) + x.set_state(0, list(reversed(range(10)))) + + with self.subTest("different node class"): + new = Model() + new.constant(10) + + with model.states.to_file() as f: + with self.assertRaises(ValueError): + new.states.from_file(f) + + # todo: uncomment once we have proper node equality testing + # with self.subTest("different node shape"): + # new = Model() + # new.list(9) + + # with model.states.to_file() as f: + # with self.assertRaises(ValueError): + # new.states.from_file(f)