From 3aab9cc23017f47beea60e481dde6f9f6d28cac4 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Fri, 29 May 2026 12:02:23 -0400 Subject: [PATCH 01/13] Add DisjointCoverNode to C++ --- .../dwave-optimization/nodes/set_routines.hpp | 40 +++ dwave/optimization/src/nodes/set_routines.cpp | 173 ++++++++++++ tests/cpp/nodes/test_set_routines.cpp | 266 ++++++++++++++++-- 3 files changed, 461 insertions(+), 18 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index 0ce7def66..6b5c5c19f 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -75,4 +75,44 @@ class IsInNode : public ArrayOutputMixin { const Array* test_elements_ptr_; }; +class DisjointCoverNode : public ScalarOutputMixin { + public: + DisjointCoverNode(ssize_t primary_set_size, std::vector node_ptrs); + + // Node overloads ********** + + /// @copydoc Node::commit() + void commit(State& state) const override; + + /// @copydoc Node::initialize_state() + void initialize_state(State& state) const override; + + /// @copydoc Node::propagate() + void propagate(State& state) const override; + + /// @copydoc Node::revert() + void revert(State& state) const override; + + // Array overloads ********** + + /// @copydoc Array::buff() + double const* buff(const State& state) const override; + + /// @copydoc Array::diff() + std::span diff(const State& state) const override; + + /// @copydoc Array::integral() + bool integral() const override; + + /// @copydoc Array::min() + double min() const override; + + /// @copydoc Array::max() + double max() const override; + + private: + ssize_t primary_set_size_; + std::vector operands_; +}; + } // namespace dwave::optimization diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index a15301a4b..f0e398610 100644 --- a/dwave/optimization/src/nodes/set_routines.cpp +++ b/dwave/optimization/src/nodes/set_routines.cpp @@ -279,4 +279,177 @@ ssize_t IsInNode::size_diff(const State& state) const { SizeInfo IsInNode::sizeinfo() const { return element_ptr_->sizeinfo(); } +// DisjointCoverNode ******************************************************************* + +struct DisjointCoverNodeData : public NodeStateData { + private: + // simple wrapper around ssize_t with default value 1, indicating no violations + struct Count_ { + ssize_t value = 1; + + Count_& operator=(const ssize_t& rhs) { + this->value = rhs; + return *this; + } + + Count_& operator+=(const ssize_t& rhs) { + this->value += rhs; + return *this; + } + + Count_& operator-=(const ssize_t& rhs) { + this->value -= rhs; + return *this; + } + + bool operator==(const ssize_t& rhs) { return this->value == rhs; } + }; + + public: + DisjointCoverNodeData(std::vector& count) : is_disjoint_cover(0, 0.0, 0.0) { + // Record whether the count is not equal to 1 in the count_violations map + for (ssize_t i = 0; i < count.size(); ++i) { + if (count[i] != 1) { + count_violations[i] = count[i]; + } + } + is_disjoint_cover.value = static_cast(count_violations.size() == 0); + is_disjoint_cover.old = is_disjoint_cover.value; + }; + + void propagate() { + // Incorporate changed elements diff into the count_violations map + for (const auto& element : elements_decremented) { + count_violations[element] -= 1; + } + for (const auto& element : elements_incremented) { + count_violations[element] += 1; + } + // Take out any non-violations + for (auto it = count_violations.begin(); it != count_violations.end();) { + if (it->second == 1) { + it = count_violations.erase(it); + } else { + ++it; + } + } + is_disjoint_cover.value = static_cast(count_violations.size() == 0); + }; + + void commit() { + elements_decremented.clear(); + elements_incremented.clear(); + is_disjoint_cover.old = is_disjoint_cover.value; + }; + + void revert() { + for (const auto& element : elements_decremented) { + // Reverse the decrement + count_violations[element] += 1; + } + for (const auto& element : elements_incremented) { + // Reverse the increment + count_violations[element] -= 1; + } + // We'll leave non-violation entries in count_violations - to be cleared in the next + // propagate + elements_decremented.clear(); + elements_incremented.clear(); + is_disjoint_cover.value = is_disjoint_cover.old; + }; + + // The actual logical output; is_disjoint_cover.value is whether the current state is a disjoint + // cover + Update is_disjoint_cover; + // count_violations[i] = the number of times that element `i` appears in the predecessors, if + // not equal to 1 + std::unordered_map count_violations; + // The "diffs" - lists of elements that were removed (respectively, inserted) in the predecessor + // diffs + std::vector elements_decremented; + std::vector elements_incremented; +}; + +DisjointCoverNode::DisjointCoverNode(ssize_t primary_set_size, std::vector node_ptrs) : + ScalarOutputMixin(), primary_set_size_(primary_set_size) { + for (const auto& node : node_ptrs) { + if (!node->integral()) { + throw std::invalid_argument("Predecessors of DisjointCoverNode must be integral"); + } + if (!(node->max() < primary_set_size)) { + throw std::invalid_argument("Predecessor exceeds primary set size"); + } + if (!(node->min() >= 0)) { + throw std::invalid_argument("Predecessor exceeds primary set size"); + } + add_predecessor_(node); + operands_.push_back(node); + } +} + +void DisjointCoverNode::initialize_state(State& state) const { + std::vector count(primary_set_size_, 0); + for (const auto& node : operands_) { + for (const auto& value : node->view(state)) { + ssize_t element = static_cast(value); + // Should not be possible because of checks in constructor + assert(element < primary_set_size_); + assert(element >= 0); + count[element] += 1; + } + } + emplace_data_ptr_(state, count); +} + +void DisjointCoverNode::commit(State& state) const { + data_ptr_(state)->commit(); +} + +void DisjointCoverNode::propagate(State& state) const { + DisjointCoverNodeData* data = data_ptr_(state); + + bool no_update = true; + for (const auto& pred : operands_) { + for (const auto& update : pred->diff(state)) { + no_update = false; + if (!update.placed()) { // i.e. removed or changed. + auto element = static_cast(update.old); + data->elements_decremented.push_back(element); + } + if (!update.removed()) { // i.e. placed or changed. + auto element = static_cast(update.value); + data->elements_incremented.push_back(element); + } + } + } + + // If no update, this node is unchanged + if (no_update) { + return; + } + // Else, propagate the populated diffs to rest of the state + data->propagate(); +} + +void DisjointCoverNode::revert(State& state) const { + data_ptr_(state)->revert(); +} + +double const* DisjointCoverNode::buff(const State& state) const { + return &data_ptr_(state)->is_disjoint_cover.value; +} + +std::span DisjointCoverNode::diff(const State& state) const { + auto data = data_ptr_(state); + return std::span( + &data->is_disjoint_cover, data->is_disjoint_cover.old != data->is_disjoint_cover.value + ); +} + +bool DisjointCoverNode::integral() const { return true; } + +double DisjointCoverNode::min() const { return 0.0; } + +double DisjointCoverNode::max() const { return 1.0; } + } // namespace dwave::optimization diff --git a/tests/cpp/nodes/test_set_routines.cpp b/tests/cpp/nodes/test_set_routines.cpp index 6770605f5..70f8ab576 100644 --- a/tests/cpp/nodes/test_set_routines.cpp +++ b/tests/cpp/nodes/test_set_routines.cpp @@ -231,39 +231,269 @@ TEST_CASE("IsInNode") { } GIVEN("Two dynamic set nodes and an isin node") { - auto set1_ptr = graph.emplace_node(6); - auto set2_ptr = graph.emplace_node(6); - auto isin_ptr = graph.emplace_node(set1_ptr, set2_ptr); - graph.emplace_node(isin_ptr); + auto set1_ptr = graph.emplace_node(6); + auto set2_ptr = graph.emplace_node(6); + auto isin_ptr = graph.emplace_node(set1_ptr, set2_ptr); + graph.emplace_node(isin_ptr); - CHECK(isin_ptr->size() == -1); + CHECK(isin_ptr->size() == -1); - WHEN("We initialize a state") { - auto state = graph.initialize_state(); + WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + AND_WHEN("We assign the set nodes and propagate") { + set1_ptr->assign(state, std::vector{2}); + set2_ptr->assign(state, std::vector{2}); + graph.propagate(state); + + THEN("The isin's state is correct") { + CHECK_THAT(isin_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We commit, shrink the state of both set nodes, and propagate") { + graph.commit(state); + + set1_ptr->shrink(state); + set2_ptr->shrink(state); - AND_WHEN("We assign the set nodes and propagate") { - set1_ptr->assign(state, std::vector{2}); - set2_ptr->assign(state, std::vector{2}); graph.propagate(state); THEN("The isin's state is correct") { - CHECK_THAT(isin_ptr->view(state), RangeEquals({1.0})); + CHECK(isin_ptr->view(state).size() == 0); } + } + } + } + } +} - AND_WHEN("We commit, shrink the state of both set nodes, and propagate") { - graph.commit(state); +TEST_CASE("DisjointCoverNode") { + auto graph = Graph(); - set1_ptr->shrink(state); - set2_ptr->shrink(state); + GIVEN("Two constant nodes that are disjoint") { + auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2}); + auto c2_ptr = graph.emplace_node(std::vector{3, 4}); - graph.propagate(state); + THEN("We can construct a DisjointCover node") { + auto dc_ptr = + graph.emplace_node(5, std::vector{c1_ptr, c2_ptr}); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + } + } + THEN("We can construct a DisjointCover node with a larger primary set size") { + auto dc_ptr = + graph.emplace_node(6, std::vector{c1_ptr, c2_ptr}); + graph.emplace_node(dc_ptr); - THEN("The isin's state is correct") { - CHECK(isin_ptr->view(state).size() == 0); + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + THEN("We cannot construct a DisjointCover node with a smaller primary set size") { + CHECK_THROWS( + graph.emplace_node(4, std::vector{c1_ptr, c2_ptr}) + ); + } + } + + GIVEN("Two constant nodes that are disjoint but not sets") { + auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2, 2}); + auto c2_ptr = graph.emplace_node(std::vector{3, 4}); + + THEN("We can construct a DisjointCover node") { + auto dc_ptr = + graph.emplace_node(5, std::vector{c1_ptr, c2_ptr}); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + + GIVEN("Two set nodes") { + auto set1_ptr = graph.emplace_node(4); + auto set2_ptr = graph.emplace_node(5); + + THEN("We can construct a DisjointCover node") { + auto dc_ptr = graph.emplace_node( + 5, std::vector{set1_ptr, set2_ptr} + ); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state with disjoint sets that cover the primary set") { + auto state = graph.initialize_state(); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{3, 4}); + graph.propagate(state); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{3}); + graph.propagate(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); } } } } + AND_WHEN("We initialize a state with not-disjoint sets") { + auto state = graph.initialize_state(); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{2, 3, 4}); + graph.propagate(state); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + set1_ptr->assign(state, std::vector{3, 1, 2}); + set2_ptr->assign(state, std::vector{4, 0}); + graph.propagate(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + AND_WHEN("We initialize a state with disjoint sets that do not cover the primary set") { + auto state = graph.initialize_state(); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{3}); + graph.propagate(state); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + set1_ptr->assign(state, std::vector{0, 1, 3}); + set2_ptr->assign(state, std::vector{2, 4}); + graph.propagate(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + } + } + + GIVEN("Three list nodes") { + auto list1_ptr = graph.emplace_node(5, 0, 5); + auto list2_ptr = graph.emplace_node(5, 0, 5); + auto list3_ptr = graph.emplace_node(5, 0, 5); + + THEN("We can construct a DisjointCover node") { + auto dc_ptr = graph.emplace_node( + 5, std::vector{list1_ptr, list2_ptr, list3_ptr} + ); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state with disjoint lists that cover the primary set") { + auto state = graph.initialize_state(); + list1_ptr->assign(state, std::vector{0, 1, 2}); + list2_ptr->assign(state, std::vector{3, 4}); + list3_ptr->assign(state, std::vector{}); + graph.propagate(state); + + THEN("The initial DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + list1_ptr->exchange(state, 0, 2); + graph.propagate(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + } + AND_WHEN("We commit and change the state") { + graph.commit(state); + list1_ptr->shrink(state); + graph.propagate(state); + + THEN("The DisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } } } +} + } // namespace dwave::optimization From cdcb924f0f730c719295f79ec35004acbdd1133d Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Sun, 31 May 2026 11:32:43 -0400 Subject: [PATCH 02/13] Add DisjointCover symbol to python interface --- .../dwave-optimization/nodes/set_routines.hpp | 3 + .../libcpp/nodes/set_routines.pxd | 3 + dwave/optimization/symbols/__init__.py | 3 +- dwave/optimization/symbols/set_routines.pyi | 2 + dwave/optimization/symbols/set_routines.pyx | 69 ++++++++++++++++++- tests/test_symbols.py | 46 +++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index 6b5c5c19f..7587c214b 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -110,6 +110,9 @@ class DisjointCoverNode : public ScalarOutputMixin { /// @copydoc Array::max() double max() const override; + /// The size of the primary set to be covered + ssize_t primary_set_size() const { return primary_set_size_; }; + private: ssize_t primary_set_size_; std::vector operands_; diff --git a/dwave/optimization/libcpp/nodes/set_routines.pxd b/dwave/optimization/libcpp/nodes/set_routines.pxd index 10bd7a1fa..94b7afdda 100644 --- a/dwave/optimization/libcpp/nodes/set_routines.pxd +++ b/dwave/optimization/libcpp/nodes/set_routines.pxd @@ -18,3 +18,6 @@ from dwave.optimization.libcpp.graph cimport ArrayNode cdef extern from "dwave-optimization/nodes/set_routines.hpp" namespace "dwave::optimization" nogil: cdef cppclass IsInNode(ArrayNode): pass + + cdef cppclass DisjointCoverNode(ArrayNode): + Py_ssize_t primary_set_size() const diff --git a/dwave/optimization/symbols/__init__.py b/dwave/optimization/symbols/__init__.py index 478bf7116..7036685d2 100644 --- a/dwave/optimization/symbols/__init__.py +++ b/dwave/optimization/symbols/__init__.py @@ -91,7 +91,7 @@ Prod, Sum, ) -from dwave.optimization.symbols.set_routines import IsIn +from dwave.optimization.symbols.set_routines import IsIn, DisjointCover from dwave.optimization.symbols.softmax import SoftMax from dwave.optimization.symbols.sorting import ArgSort from dwave.optimization.symbols.statistics import Mean @@ -133,6 +133,7 @@ "Cos", "DisjointBitSet", "DisjointBitSets", + "DisjointCover", "DisjointList", "DisjointLists", "Divide", diff --git a/dwave/optimization/symbols/set_routines.pyi b/dwave/optimization/symbols/set_routines.pyi index 83ff3422e..1224bbfc3 100644 --- a/dwave/optimization/symbols/set_routines.pyi +++ b/dwave/optimization/symbols/set_routines.pyi @@ -15,3 +15,5 @@ from dwave.optimization.model import ArraySymbol as _ArraySymbol class IsIn(_ArraySymbol): ... + +class DisjointCover(_ArraySymbol): ... diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index bb57618e0..c1e7ed6a4 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -14,10 +14,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import collections.abc +import json + from cython.operator cimport typeid +from libcpp.vector cimport vector + -from dwave.optimization._model cimport _Graph, _register, ArraySymbol -from dwave.optimization.libcpp.nodes.set_routines cimport IsInNode +from dwave.optimization._model cimport _Graph, _register, ArraySymbol, Symbol +from dwave.optimization.libcpp cimport dynamic_cast_ptr +from dwave.optimization.libcpp.graph cimport ArrayNode +from dwave.optimization.libcpp.nodes.set_routines cimport IsInNode, DisjointCoverNode cdef class IsIn(ArraySymbol): @@ -43,3 +50,61 @@ cdef class IsIn(ArraySymbol): self.initialize_arraynode(model, ptr) _register(IsIn, typeid(IsInNode)) + + +cdef class DisjointCover(ArraySymbol): + """Tests whether the symbols are disjoint, set-like, and cover a set of integers + + See Also: + + .. versionadded:: + """ + def __init__(self, Py_ssize_t n, object inputs): + if (not isinstance(inputs, collections.abc.Sequence) or + not all(isinstance(arr, ArraySymbol) for arr in inputs)): + raise TypeError("disjoint_cover takes a sequence of array symbols") + + if len(inputs) < 1: + raise ValueError("need at least one array symbol to to form a disjoint cover") + + cdef _Graph model = inputs[0].model + cdef vector[ArrayNode*] cppinputs + + for symbol in inputs: + if symbol.model is not model: + raise ValueError("all predecessors must be from the same model") + cppinputs.push_back((symbol).array_ptr) + + self.primary_set_size = n + cdef DisjointCoverNode* ptr = model._graph.emplace_node[DisjointCoverNode](n, cppinputs) + self.initialize_arraynode(model, ptr) + + @classmethod + def _from_symbol(cls, Symbol symbol): + cdef DisjointCoverNode* ptr = dynamic_cast_ptr[DisjointCoverNode](symbol.node_ptr) + if not ptr: + raise TypeError(f"given symbol cannot construct a {cls.__name__}") + cdef DisjointCover sym = cls.__new__(cls) + sym.primary_set_size = ptr.primary_set_size() + sym.initialize_arraynode(symbol.model, ptr) + return sym + + @classmethod + def _from_zipfile(cls, zf, directory, _Graph model, predecessors): + with zf.open(directory + "args.json", "r") as f: + args = json.load(f) + return cls(args["primary_set_size"], list(predecessors)) + + def _into_zipfile(self, zf, directory): + super()._into_zipfile(zf, directory) + + encoder = json.JSONEncoder(separators=(',', ':')) + + # get the non-array args + args = dict() + args.update(primary_set_size=int(self.primary_set_size)) + zf.writestr(directory + "args.json", encoder.encode(args)) + + cdef Py_ssize_t primary_set_size + +_register(DisjointCover, typeid(DisjointCoverNode)) diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 8c5bf18b7..bcad42d19 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1497,6 +1497,52 @@ def test_state_serialization_explicit(self): np.testing.assert_array_equal(ys[2].state(), [0, 0, 0, 0, 1]) +class TestDisjointCover(utils.SymbolTests): + def generate_symbols(self): + model = Model() + sets = [ + model.constant([0, 1, 2]), + model.constant([3, 4]) + ] + cover = dwave.optimization.symbols.DisjointCover(5, sets) + + with model.lock(): + yield cover + + def test(self): + from dwave.optimization.symbols import DisjointCover + model = Model() + sets = [ + model.constant([0, 1, 2]), + model.constant([3, 4]) + ] + cover = dwave.optimization.symbols.DisjointCover(5, sets) + self.assertIsInstance(cover, DisjointCover) + + def test_state(self): + model = Model() + # a disjoint cover + sets = [ + model.constant([0, 1, 2]), + model.constant([3, 4]) + ] + cover = dwave.optimization.symbols.DisjointCover(5, sets) + model.states.resize(1) + with model.lock(): + expected = np.array([1.0]) + np.testing.assert_array_almost_equal(cover.state(0), expected) + + # not disjoint + sets = [ + model.constant([0, 1, 2]), + model.constant([2, 3, 4]) + ] + cover = dwave.optimization.symbols.DisjointCover(5, sets) + with model.lock(): + expected = np.array([0.0]) + np.testing.assert_array_almost_equal(cover.state(0), expected) + + class TestDisjointListsVariable(utils.SymbolTests): def test_inequality(self): # TODO re-enable this once equality has been fixed From 461fb4e44064900086394a68c6a921e9cb93b567 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Sun, 31 May 2026 11:53:42 -0400 Subject: [PATCH 03/13] Clean up constructor --- .../dwave-optimization/nodes/set_routines.hpp | 2 +- dwave/optimization/src/nodes/set_routines.cpp | 4 +-- tests/cpp/nodes/test_set_routines.cpp | 25 ++++++++----------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index 7587c214b..be4daadea 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -77,7 +77,7 @@ class IsInNode : public ArrayOutputMixin { class DisjointCoverNode : public ScalarOutputMixin { public: - DisjointCoverNode(ssize_t primary_set_size, std::vector node_ptrs); + DisjointCoverNode(ssize_t primary_set_size, std::span node_ptrs); // Node overloads ********** diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index f0e398610..77d250c49 100644 --- a/dwave/optimization/src/nodes/set_routines.cpp +++ b/dwave/optimization/src/nodes/set_routines.cpp @@ -308,7 +308,7 @@ struct DisjointCoverNodeData : public NodeStateData { public: DisjointCoverNodeData(std::vector& count) : is_disjoint_cover(0, 0.0, 0.0) { // Record whether the count is not equal to 1 in the count_violations map - for (ssize_t i = 0; i < count.size(); ++i) { + for (ssize_t i = 0, stop = count.size(); i < stop; ++i) { if (count[i] != 1) { count_violations[i] = count[i]; } @@ -370,7 +370,7 @@ struct DisjointCoverNodeData : public NodeStateData { std::vector elements_incremented; }; -DisjointCoverNode::DisjointCoverNode(ssize_t primary_set_size, std::vector node_ptrs) : +DisjointCoverNode::DisjointCoverNode(ssize_t primary_set_size, std::span node_ptrs) : ScalarOutputMixin(), primary_set_size_(primary_set_size) { for (const auto& node : node_ptrs) { if (!node->integral()) { diff --git a/tests/cpp/nodes/test_set_routines.cpp b/tests/cpp/nodes/test_set_routines.cpp index 70f8ab576..026670a81 100644 --- a/tests/cpp/nodes/test_set_routines.cpp +++ b/tests/cpp/nodes/test_set_routines.cpp @@ -273,10 +273,10 @@ TEST_CASE("DisjointCoverNode") { GIVEN("Two constant nodes that are disjoint") { auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2}); auto c2_ptr = graph.emplace_node(std::vector{3, 4}); + std::vector sets{c1_ptr, c2_ptr}; THEN("We can construct a DisjointCover node") { - auto dc_ptr = - graph.emplace_node(5, std::vector{c1_ptr, c2_ptr}); + auto dc_ptr = graph.emplace_node(5, sets); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -293,8 +293,7 @@ TEST_CASE("DisjointCoverNode") { } } THEN("We can construct a DisjointCover node with a larger primary set size") { - auto dc_ptr = - graph.emplace_node(6, std::vector{c1_ptr, c2_ptr}); + auto dc_ptr = graph.emplace_node(6, sets); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -311,19 +310,17 @@ TEST_CASE("DisjointCoverNode") { } } THEN("We cannot construct a DisjointCover node with a smaller primary set size") { - CHECK_THROWS( - graph.emplace_node(4, std::vector{c1_ptr, c2_ptr}) - ); + CHECK_THROWS(graph.emplace_node(4, sets)); } } GIVEN("Two constant nodes that are disjoint but not sets") { auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2, 2}); auto c2_ptr = graph.emplace_node(std::vector{3, 4}); + std::vector sets{c1_ptr, c2_ptr}; THEN("We can construct a DisjointCover node") { - auto dc_ptr = - graph.emplace_node(5, std::vector{c1_ptr, c2_ptr}); + auto dc_ptr = graph.emplace_node(5, sets); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -344,11 +341,10 @@ TEST_CASE("DisjointCoverNode") { GIVEN("Two set nodes") { auto set1_ptr = graph.emplace_node(4); auto set2_ptr = graph.emplace_node(5); + std::vector sets{set1_ptr, set2_ptr}; THEN("We can construct a DisjointCover node") { - auto dc_ptr = graph.emplace_node( - 5, std::vector{set1_ptr, set2_ptr} - ); + auto dc_ptr = graph.emplace_node(5, sets); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -450,11 +446,10 @@ TEST_CASE("DisjointCoverNode") { auto list1_ptr = graph.emplace_node(5, 0, 5); auto list2_ptr = graph.emplace_node(5, 0, 5); auto list3_ptr = graph.emplace_node(5, 0, 5); + std::vector lists{list1_ptr, list2_ptr, list3_ptr}; THEN("We can construct a DisjointCover node") { - auto dc_ptr = graph.emplace_node( - 5, std::vector{list1_ptr, list2_ptr, list3_ptr} - ); + auto dc_ptr = graph.emplace_node(5, lists); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); From 1eae8cab4684eb0753aba11e3af289f49e361947 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Mon, 1 Jun 2026 12:24:41 -0400 Subject: [PATCH 04/13] Add DisjointListsContainer and convenience method to Model --- dwave/optimization/model.py | 141 ++++++++++++++++++++++++++++++++++++ tests/test_symbols.py | 130 +++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 2cfa787b1..d324d9622 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -138,6 +138,77 @@ def clear_cache(self): # definition. +class DisjointListsContainer: + """Container for Disjoint-lists decision-variable symbols. + + See Also: + :meth:`~dwave.optimization.model.Model.disjoint_lists_container`: + Instantiation and usage of this symbol. + """ + def __init__(self, primary_set_size: int, lists: list[ListVariable]): + self.primary_set_size_ = primary_set_size + self.disjoint_lists_ = lists + + def __getitem__(self, index: int) -> ListVariable: + return self.disjoint_lists_[index] + + def set_state(self, index: int, state): + r"""Set the state of the disjoint-lists symbol. + + Args: + index (int): + Index of the state to set + state (list[list, ...]): + Assignment of values for the state. The specified state must be + a partition of ``range(primary_set_size)`` into + :meth:`.num_disjoint_lists` partitions as a list of lists. + + Examples: + This example sets the state of a disjoint-lists symbol. You can + inspect the state of each list individually. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> lists_symbol = model.disjoint_lists_symbol( + ... primary_set_size=5, + ... num_disjoint_lists=3 + ... ) + >>> with model.lock(): + ... model.states.resize(1) + ... lists_symbol.set_state(0, [[0, 1, 2, 3], [4], []]) + ... for index, disjoint_list in enumerate(lists_symbol): + ... print(f"DisjointList {index}:") + ... print(disjoint_list.state(0)) + DisjointList 0: + [0. 1. 2. 3.] + DisjointList 1: + [4.] + DisjointList 2: + [] + """ + # Pass state to contained list variables + for i in range(len(state)): + self[i].set_state(index, state[i]) + + def num_disjoint_lists(self): + """Return the number of disjoint lists. + + Examples: + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> lists_symbol = model.disjoint_lists_symbol( + ... primary_set_size=5, + ... num_disjoint_lists=3) + >>> lists_symbol.num_disjoint_lists() == 3 + True + """ + return len(self.disjoint_lists_) + + def primary_set_size(self): + """Return the total number of elements in the partitioned lists.""" + return self.primary_set_size_ + + class Model(_Graph): """Nonlinear model. @@ -601,6 +672,76 @@ def disjoint_lists_symbol( return disjoint_lists + def disjoint_lists_container( + self, + primary_set_size: int, + num_disjoint_lists: int, + ) -> DisjointListsContainer: + """Create disjoint list symbols as decision variables. + + Disjoint list symbols divide a set of the elements of + ``range(primary_set_size)`` into ``num_disjoint_lists`` ordered + partitions, where the division is assigned as a solution to the problem + being modeled. + + Creates ``num_disjoint_lists`` list variables and adds a DisjointCover constraint. + + Args: + primary_set_size: Number of elements in the primary set to + be partitioned into disjoint lists. Must be non-negative. + num_disjoint_lists: Number of disjoint lists. Must be positive. + + Returns: + An object organizing the list symbols at the root of the + :term:`directed acyclic graph` for the model. + + Examples: + This example creates a symbol of 10 elements that is divided + into 4 lists. + + >>> from dwave.optimization.model import Model + >>> model = Model() + >>> disjoint_lists = model.disjoint_lists_container(10, 4) + >>> disjoint_lists.primary_set_size() + 10 + >>> disjoint_lists.num_disjoint_lists() + 4 + >>> with model.lock(): + ... model.states.resize(1) + ... disjoint_lists.set_state(0, [[0, 1, 2], [3, 5, 6], [4], [7, 8, 9]]) + ... for i, disjoint_list in enumerate(disjoint_lists): + ... print(f"Element {i}: {disjoint_list.state(0)}") + Element 0: [0. 1. 2.] + Element 1: [3. 5. 6.] + Element 2: [4.] + Element 3: [7. 8. 9.] + + .. figure:: /_images/disjoint_lists_symbol.svg + :width: 500 px + :name: dwave-optimization-disjoint-lists-symbol-example + :alt: Image of the model constructed in this example + + Visualization of the model as a :term:`directed acyclic graph`. + See the :func:`~dwave.optimization.model.Model.to_networkx` + function for information on visualizing models. + + See Also: + :class:`~dwave.optimization.symbols.DisjointListsContainer`, + :class:`~dwave.optimization.symbols.DisjointCover`, + :class:`~dwave.optimization.symbols.List`: Generated + symbols + + :meth:`.disjoint_bit_sets`, :meth:`.list`, :meth:`.set` + + :meth:`.iter_decisions`, :meth:`.iter_successors` + """ + from dwave.optimization.symbols import DisjointCover # avoid circular import + lists = [self.list(primary_set_size, min_size=0, max_size=primary_set_size) for _ in range(num_disjoint_lists)] + self.add_constraint(DisjointCover(primary_set_size, lists)) + disjoint_lists = DisjointListsContainer(primary_set_size, lists) + + return disjoint_lists + def feasible(self, index: int = 0) -> bool: """Check the feasibility of a state. diff --git a/tests/test_symbols.py b/tests/test_symbols.py index bcad42d19..9343971bb 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1543,6 +1543,136 @@ def test_state(self): np.testing.assert_array_almost_equal(cover.state(0), expected) +# Strictly speaking, not a symbol, but thematically it makes sense here +class TestDisjointListsContainer(utils.SymbolTests): + def test_inequality(self): + # TODO re-enable this once equality has been fixed + pass + + def generate_symbols(self): + model = Model() + d = model.disjoint_lists_container(10, 4) + model.lock() + yield from d + + def test(self): + model = Model() + + dls = model.disjoint_lists_container(10, 4) + + self.assertEqual(dls.primary_set_size(), 10) + self.assertEqual(dls.num_disjoint_lists(), 4) + + def test_indexing(self): + model = Model() + + dls = model.disjoint_lists_container(10, 4) + + self.assertEqual(len(list(dls)), 4) + self.assertIsInstance(dls[0], dwave.optimization.symbols.ListVariable) + self.assertIsInstance(dls[3], dwave.optimization.symbols.ListVariable) + + with self.assertRaises(IndexError): + dls[4] + + def test_construction(self): + model = Model() + + with self.assertRaises(ValueError): + model.disjoint_lists_container(-5, 1) + with self.assertRaises(ValueError): + model.disjoint_lists_container(1, -5) + with self.assertRaises(ValueError): + model.disjoint_lists_container(0, 1) + + def test_num_returned_nodes(self): + model = Model() + + model.disjoint_lists_container(10, 4) + + # One node for each of the 4 successor lists, plus a DisjointCover node + self.assertEqual(model.num_nodes(), 5) + + def test_set_state(self): + with self.subTest("array-like output lists"): + model = Model() + model.states.resize(1) + x = model.disjoint_lists_container(5, 3) + model.lock() + + x.set_state(0, [[0, 1], [2, 3], [4]]) + + np.testing.assert_array_equal(x[0].state(), [0, 1]) + np.testing.assert_array_equal(x[1].state(), [2, 3]) + np.testing.assert_array_equal(x[2].state(), [4]) + + with self.subTest("invalid state index"): + model = Model() + x = model.disjoint_lists_container(5, 3) + + state = [[0, 1, 2, 3, 4], [], []] + + # No states have been created + with self.assertRaisesRegex(ValueError, r"^index out of range: 0$"): + x.set_state(0, state) + with self.assertRaisesRegex(ValueError, r"^index out of range: -1$"): + x.set_state(-1, state) + + # Some states have been created + model.states.resize(5) + with self.assertRaisesRegex(ValueError, r"^index out of range: 5$"): + x.set_state(5, state) + with self.assertRaisesRegex(ValueError, r"^index out of range: -1$"): + x.set_state(-1, state) + + with self.subTest("non-integer"): + # gets translated into integer according to NumPy rules + model = Model() + model.states.resize(1) + x = model.disjoint_lists_container(5, 3) + model.lock() + + x.set_state(0, [[4.5, 3, 2, 1, 0], [], []]) + np.testing.assert_array_equal(x[0].state(), [4, 3, 2, 1, 0]) + + with self.subTest("invalid"): + model = Model() + model.states.resize(1) + x = model.disjoint_lists_container(5, 3) + model.lock() + + with self.assertRaisesRegex( + ValueError, r"^values must be a subset of range\(5\)$" + ): + x.set_state(0, [[0, 0, 1, 2, 3], [], []]) + + # wrong number of lists + with self.assertRaises(IndexError): + x.set_state(0, [[0, 1, 2, 3, 4], [], [], []]) + + with self.subTest("infeasible"): + model = Model() + model.states.resize(1) + x = model.disjoint_lists_container(5, 3) + model.lock() + + # Not disjoint + x.set_state(0, [[0, 1, 2, 3], [3], [4]]) + self.assertFalse(model.feasible(0)) + + # Not a cover + x.set_state(0, [[0, 1, 2], [], [4]]) + self.assertFalse(model.feasible(0)) + + def test_state_size(self): + model = Model() + + d = model.disjoint_lists_container(10, 4) + + for s in d: + self.assertEqual(s.state_size(), 10 * 8) + + class TestDisjointListsVariable(utils.SymbolTests): def test_inequality(self): # TODO re-enable this once equality has been fixed From d739b4084dbabe363847358f496e171ef53aaf68 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Fri, 5 Jun 2026 14:43:30 -0400 Subject: [PATCH 05/13] Change DisjointCover -> IsDisjointCover And don't bother with the "container" object --- .../dwave-optimization/nodes/set_routines.hpp | 80 ++-- .../libcpp/nodes/set_routines.pxd | 6 +- dwave/optimization/mathematical.py | 40 ++ dwave/optimization/model.py | 150 +----- dwave/optimization/src/nodes/set_routines.cpp | 349 +++++++------- dwave/optimization/symbols/__init__.py | 4 +- dwave/optimization/symbols/set_routines.pyi | 4 +- dwave/optimization/symbols/set_routines.pyx | 64 +-- tests/cpp/nodes/test_set_routines.cpp | 448 +++++++++--------- tests/test_symbols.py | 142 +----- 10 files changed, 532 insertions(+), 755 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index be4daadea..78210a195 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -22,9 +22,9 @@ namespace dwave::optimization { -class IsInNode : public ArrayOutputMixin { +class IsDisjointCoverNode : public ScalarOutputMixin { public: - IsInNode(ArrayNode* element_ptr, ArrayNode* test_elements_ptr); + IsDisjointCoverNode(ssize_t primary_set_size, std::span node_ptrs); /// @copydoc Array::buff() double const* buff(const State& state) const override; @@ -41,11 +41,14 @@ class IsInNode : public ArrayOutputMixin { /// @copydoc Array::integral() bool integral() const override; + /// @copydoc Array::max() + double max() const override; + /// @copydoc Array::min() double min() const override; - /// @copydoc Array::max() - double max() const override; + /// The size of the primary set to be covered + ssize_t primary_set_size() const { return primary_set_size_; }; /// @copydoc Node::propagate() void propagate(State& state) const override; @@ -53,69 +56,62 @@ class IsInNode : public ArrayOutputMixin { /// @copydoc Node::revert() void revert(State& state) const override; - using Array::shape; - - /// @copydoc Array::shape() - std::span shape(const State& state) const override; - - using Array::size; - - /// @copydoc Array::size() - ssize_t size(const State& state) const override; - - /// @copydoc Array::size_diff() - ssize_t size_diff(const State& state) const override; - - /// @copydoc Array::sizeinfo() - SizeInfo sizeinfo() const override; - private: - // these are redundant, but convenient - const Array* element_ptr_; - const Array* test_elements_ptr_; + ssize_t primary_set_size_; + std::vector operands_; }; -class DisjointCoverNode : public ScalarOutputMixin { +class IsInNode : public ArrayOutputMixin { public: - DisjointCoverNode(ssize_t primary_set_size, std::span node_ptrs); + IsInNode(ArrayNode* element_ptr, ArrayNode* test_elements_ptr); - // Node overloads ********** + /// @copydoc Array::buff() + double const* buff(const State& state) const override; /// @copydoc Node::commit() void commit(State& state) const override; + /// @copydoc Array::diff() + std::span diff(const State& state) const override; + /// @copydoc Node::initialize_state() void initialize_state(State& state) const override; + /// @copydoc Array::integral() + bool integral() const override; + + /// @copydoc Array::min() + double min() const override; + + /// @copydoc Array::max() + double max() const override; + /// @copydoc Node::propagate() void propagate(State& state) const override; /// @copydoc Node::revert() void revert(State& state) const override; - // Array overloads ********** - - /// @copydoc Array::buff() - double const* buff(const State& state) const override; + using Array::shape; - /// @copydoc Array::diff() - std::span diff(const State& state) const override; + /// @copydoc Array::shape() + std::span shape(const State& state) const override; - /// @copydoc Array::integral() - bool integral() const override; + using Array::size; - /// @copydoc Array::min() - double min() const override; + /// @copydoc Array::size() + ssize_t size(const State& state) const override; - /// @copydoc Array::max() - double max() const override; + /// @copydoc Array::size_diff() + ssize_t size_diff(const State& state) const override; - /// The size of the primary set to be covered - ssize_t primary_set_size() const { return primary_set_size_; }; + /// @copydoc Array::sizeinfo() + SizeInfo sizeinfo() const override; private: - ssize_t primary_set_size_; - std::vector operands_; + // these are redundant, but convenient + const Array* element_ptr_; + const Array* test_elements_ptr_; }; } // namespace dwave::optimization diff --git a/dwave/optimization/libcpp/nodes/set_routines.pxd b/dwave/optimization/libcpp/nodes/set_routines.pxd index 94b7afdda..64d089da5 100644 --- a/dwave/optimization/libcpp/nodes/set_routines.pxd +++ b/dwave/optimization/libcpp/nodes/set_routines.pxd @@ -16,8 +16,8 @@ from dwave.optimization.libcpp.graph cimport ArrayNode cdef extern from "dwave-optimization/nodes/set_routines.hpp" namespace "dwave::optimization" nogil: + cdef cppclass IsDisjointCoverNode(ArrayNode): + Py_ssize_t primary_set_size() const + cdef cppclass IsInNode(ArrayNode): pass - - cdef cppclass DisjointCoverNode(ArrayNode): - Py_ssize_t primary_set_size() const diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index f0aac7c1c..afbc92a75 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -38,6 +38,7 @@ Exp, Expit, Extract, + IsDisjointCover, IsIn, LessEqual, LinearProgram, @@ -95,6 +96,7 @@ "expit", "extract", "hstack", + "is_disjoint_cover", "isin", "less_equal", "linprog", @@ -1110,6 +1112,44 @@ def hstack(arrays: collections.abc.Sequence[ArraySymbol]) -> ArraySymbol: return concatenate(arrays, 1) +def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDisjointCover: + """Return whether the symbols are disjoint, set-like, and cover a set of integers. + + Determines whether a collection of array symbols is disjoint and the union equals a fixed set. + + Args: + primary_set_size: Number of elements in the primary set: {0, 1, ..., primary_set_size - 1}. + Must be non-negative. + subsets: List of array symbols to test whether they are disjoint and cover the primary set + + Returns: + A scalar boolean-valued array symbol indicating whether the subsets + + Examples: + >>> from dwave.optimization.model import Model + >>> from dwave.optimization.mathematical import is_disjoint_cover + ... + >>> model = Model() + >>> model.states.resize(1) + >>> subsets = [] + >>> subsets.append(model.constant([0, 1]) + >>> subsets.append(model.constant([2, 3, 4]) + >>> subsets.append(model.constant([]) + >>> is_disjoint = is_disjoint_cover(5, subsets) + >>> with model.lock(): + ... print(is_disjoint.state(0)) + [1.] + + See Also: + :class:`~dwave.optimization.symbols.IsDisjointCover`: Generated symbol + + :func:`.isin` + + .. versionadded:: 0.7.1 + """ + return IsDisjointCover(primary_set_size, subsets) + + def isin(element: ArraySymbol, test_elements: ArraySymbol) -> IsIn: """Return which values of one array symbol are in another. diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index d324d9622..b3c044f7c 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -138,77 +138,6 @@ def clear_cache(self): # definition. -class DisjointListsContainer: - """Container for Disjoint-lists decision-variable symbols. - - See Also: - :meth:`~dwave.optimization.model.Model.disjoint_lists_container`: - Instantiation and usage of this symbol. - """ - def __init__(self, primary_set_size: int, lists: list[ListVariable]): - self.primary_set_size_ = primary_set_size - self.disjoint_lists_ = lists - - def __getitem__(self, index: int) -> ListVariable: - return self.disjoint_lists_[index] - - def set_state(self, index: int, state): - r"""Set the state of the disjoint-lists symbol. - - Args: - index (int): - Index of the state to set - state (list[list, ...]): - Assignment of values for the state. The specified state must be - a partition of ``range(primary_set_size)`` into - :meth:`.num_disjoint_lists` partitions as a list of lists. - - Examples: - This example sets the state of a disjoint-lists symbol. You can - inspect the state of each list individually. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> lists_symbol = model.disjoint_lists_symbol( - ... primary_set_size=5, - ... num_disjoint_lists=3 - ... ) - >>> with model.lock(): - ... model.states.resize(1) - ... lists_symbol.set_state(0, [[0, 1, 2, 3], [4], []]) - ... for index, disjoint_list in enumerate(lists_symbol): - ... print(f"DisjointList {index}:") - ... print(disjoint_list.state(0)) - DisjointList 0: - [0. 1. 2. 3.] - DisjointList 1: - [4.] - DisjointList 2: - [] - """ - # Pass state to contained list variables - for i in range(len(state)): - self[i].set_state(index, state[i]) - - def num_disjoint_lists(self): - """Return the number of disjoint lists. - - Examples: - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> lists_symbol = model.disjoint_lists_symbol( - ... primary_set_size=5, - ... num_disjoint_lists=3) - >>> lists_symbol.num_disjoint_lists() == 3 - True - """ - return len(self.disjoint_lists_) - - def primary_set_size(self): - """Return the total number of elements in the partitioned lists.""" - return self.primary_set_size_ - - class Model(_Graph): """Nonlinear model. @@ -662,6 +591,15 @@ def disjoint_lists_symbol( :meth:`.iter_decisions`, :meth:`.iter_successors` """ + warnings.warn( + "The use of Model.disjoint_lists_symbol() is deprecated " + "since dwave.optimization 0.7.1. Use " + "`from dwave.optimization.mathematical import is_disjoint_cover` " + "`lists = [model.list(primary_set_size, min_size=0) for _ in range(num_disjoint_lists)]` " + "`model.add_constraint(is_disjoint_cover(primary_set_size, lists))`", + DeprecationWarning, + ) + from dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import disjoint_lists = DisjointLists(self, primary_set_size, num_disjoint_lists) @@ -672,76 +610,6 @@ def disjoint_lists_symbol( return disjoint_lists - def disjoint_lists_container( - self, - primary_set_size: int, - num_disjoint_lists: int, - ) -> DisjointListsContainer: - """Create disjoint list symbols as decision variables. - - Disjoint list symbols divide a set of the elements of - ``range(primary_set_size)`` into ``num_disjoint_lists`` ordered - partitions, where the division is assigned as a solution to the problem - being modeled. - - Creates ``num_disjoint_lists`` list variables and adds a DisjointCover constraint. - - Args: - primary_set_size: Number of elements in the primary set to - be partitioned into disjoint lists. Must be non-negative. - num_disjoint_lists: Number of disjoint lists. Must be positive. - - Returns: - An object organizing the list symbols at the root of the - :term:`directed acyclic graph` for the model. - - Examples: - This example creates a symbol of 10 elements that is divided - into 4 lists. - - >>> from dwave.optimization.model import Model - >>> model = Model() - >>> disjoint_lists = model.disjoint_lists_container(10, 4) - >>> disjoint_lists.primary_set_size() - 10 - >>> disjoint_lists.num_disjoint_lists() - 4 - >>> with model.lock(): - ... model.states.resize(1) - ... disjoint_lists.set_state(0, [[0, 1, 2], [3, 5, 6], [4], [7, 8, 9]]) - ... for i, disjoint_list in enumerate(disjoint_lists): - ... print(f"Element {i}: {disjoint_list.state(0)}") - Element 0: [0. 1. 2.] - Element 1: [3. 5. 6.] - Element 2: [4.] - Element 3: [7. 8. 9.] - - .. figure:: /_images/disjoint_lists_symbol.svg - :width: 500 px - :name: dwave-optimization-disjoint-lists-symbol-example - :alt: Image of the model constructed in this example - - Visualization of the model as a :term:`directed acyclic graph`. - See the :func:`~dwave.optimization.model.Model.to_networkx` - function for information on visualizing models. - - See Also: - :class:`~dwave.optimization.symbols.DisjointListsContainer`, - :class:`~dwave.optimization.symbols.DisjointCover`, - :class:`~dwave.optimization.symbols.List`: Generated - symbols - - :meth:`.disjoint_bit_sets`, :meth:`.list`, :meth:`.set` - - :meth:`.iter_decisions`, :meth:`.iter_successors` - """ - from dwave.optimization.symbols import DisjointCover # avoid circular import - lists = [self.list(primary_set_size, min_size=0, max_size=primary_set_size) for _ in range(num_disjoint_lists)] - self.add_constraint(DisjointCover(primary_set_size, lists)) - disjoint_lists = DisjointListsContainer(primary_set_size, lists) - - return disjoint_lists - def feasible(self, index: int = 0) -> bool: """Check the feasibility of a state. diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index 77d250c49..81e90cc53 100644 --- a/dwave/optimization/src/nodes/set_routines.cpp +++ b/dwave/optimization/src/nodes/set_routines.cpp @@ -20,6 +20,182 @@ namespace dwave::optimization { +// IsDisjointCoverNode ******************************************************************* + +struct IsDisjointCoverNodeData : public NodeStateData { + private: + // simple wrapper around ssize_t with default value 1, indicating no violations + struct Count_ { + ssize_t value = 1; + + Count_& operator=(const ssize_t& rhs) { + this->value = rhs; + return *this; + } + + Count_& operator+=(const ssize_t& rhs) { + this->value += rhs; + return *this; + } + + Count_& operator-=(const ssize_t& rhs) { + this->value -= rhs; + return *this; + } + + bool operator==(const ssize_t& rhs) { return this->value == rhs; } + }; + + public: + IsDisjointCoverNodeData(std::vector& count) : is_disjoint_cover(0, 0.0, 0.0) { + // Record whether the count is not equal to 1 in the count_violations map + for (ssize_t i = 0, stop = count.size(); i < stop; ++i) { + if (count[i] != 1) { + count_violations[i] = count[i]; + } + } + is_disjoint_cover.value = static_cast(count_violations.size() == 0); + is_disjoint_cover.old = is_disjoint_cover.value; + }; + + void propagate() { + // Incorporate changed elements diff into the count_violations map + for (const auto& element : elements_decremented) { + count_violations[element] -= 1; + } + for (const auto& element : elements_incremented) { + count_violations[element] += 1; + } + // Take out any non-violations + for (auto it = count_violations.begin(); it != count_violations.end();) { + if (it->second == 1) { + it = count_violations.erase(it); + } else { + ++it; + } + } + is_disjoint_cover.value = static_cast(count_violations.size() == 0); + }; + + void commit() { + elements_decremented.clear(); + elements_incremented.clear(); + is_disjoint_cover.old = is_disjoint_cover.value; + }; + + void revert() { + for (const auto& element : elements_decremented) { + // Reverse the decrement + count_violations[element] += 1; + } + for (const auto& element : elements_incremented) { + // Reverse the increment + count_violations[element] -= 1; + } + // We'll leave non-violation entries in count_violations - to be cleared in the next + // propagate + elements_decremented.clear(); + elements_incremented.clear(); + is_disjoint_cover.value = is_disjoint_cover.old; + }; + + // The actual logical output; is_disjoint_cover.value is whether the current state is a disjoint + // cover + Update is_disjoint_cover; + // count_violations[i] = the number of times that element `i` appears in the predecessors, if + // not equal to 1 + std::unordered_map count_violations; + // The "diffs" - lists of elements that were removed (respectively, inserted) in the predecessor + // diffs + std::vector elements_decremented; + std::vector elements_incremented; +}; + +IsDisjointCoverNode::IsDisjointCoverNode( + ssize_t primary_set_size, + std::span node_ptrs +) : + ScalarOutputMixin(), primary_set_size_(primary_set_size) { + for (const auto& node : node_ptrs) { + if (!node->integral()) { + throw std::invalid_argument("Predecessors of DisjointCoverNode must be integral"); + } + if (!(node->max() < primary_set_size)) { + throw std::invalid_argument("Predecessor exceeds primary set size"); + } + if (!(node->min() >= 0)) { + throw std::invalid_argument("Predecessor exceeds primary set size"); + } + add_predecessor_(node); + operands_.push_back(node); + } +} + +void IsDisjointCoverNode::initialize_state(State& state) const { + std::vector count(primary_set_size_, 0); + for (const auto& node : operands_) { + for (const auto& value : node->view(state)) { + ssize_t element = static_cast(value); + // Should not be possible because of checks in constructor + assert(element < primary_set_size_); + assert(element >= 0); + count[element] += 1; + } + } + emplace_data_ptr_(state, count); +} + +void IsDisjointCoverNode::commit(State& state) const { + data_ptr_(state)->commit(); +} + +void IsDisjointCoverNode::propagate(State& state) const { + IsDisjointCoverNodeData* data = data_ptr_(state); + + bool no_update = true; + for (const auto& pred : operands_) { + for (const auto& update : pred->diff(state)) { + no_update = false; + if (!update.placed()) { // i.e. removed or changed. + auto element = static_cast(update.old); + data->elements_decremented.push_back(element); + } + if (!update.removed()) { // i.e. placed or changed. + auto element = static_cast(update.value); + data->elements_incremented.push_back(element); + } + } + } + + // If no update, this node is unchanged + if (no_update) { + return; + } + // Else, propagate the populated diffs to rest of the state + data->propagate(); +} + +void IsDisjointCoverNode::revert(State& state) const { + data_ptr_(state)->revert(); +} + +double const* IsDisjointCoverNode::buff(const State& state) const { + return &data_ptr_(state)->is_disjoint_cover.value; +} + +std::span IsDisjointCoverNode::diff(const State& state) const { + auto data = data_ptr_(state); + return std::span( + &data->is_disjoint_cover, data->is_disjoint_cover.old != data->is_disjoint_cover.value + ); +} + +bool IsDisjointCoverNode::integral() const { return true; } + +double IsDisjointCoverNode::min() const { return 0.0; } + +double IsDisjointCoverNode::max() const { return 1.0; } + // IsInNode ******************************************************************* struct IsInNodeSetData { IsInNodeSetData() = default; @@ -279,177 +455,4 @@ ssize_t IsInNode::size_diff(const State& state) const { SizeInfo IsInNode::sizeinfo() const { return element_ptr_->sizeinfo(); } -// DisjointCoverNode ******************************************************************* - -struct DisjointCoverNodeData : public NodeStateData { - private: - // simple wrapper around ssize_t with default value 1, indicating no violations - struct Count_ { - ssize_t value = 1; - - Count_& operator=(const ssize_t& rhs) { - this->value = rhs; - return *this; - } - - Count_& operator+=(const ssize_t& rhs) { - this->value += rhs; - return *this; - } - - Count_& operator-=(const ssize_t& rhs) { - this->value -= rhs; - return *this; - } - - bool operator==(const ssize_t& rhs) { return this->value == rhs; } - }; - - public: - DisjointCoverNodeData(std::vector& count) : is_disjoint_cover(0, 0.0, 0.0) { - // Record whether the count is not equal to 1 in the count_violations map - for (ssize_t i = 0, stop = count.size(); i < stop; ++i) { - if (count[i] != 1) { - count_violations[i] = count[i]; - } - } - is_disjoint_cover.value = static_cast(count_violations.size() == 0); - is_disjoint_cover.old = is_disjoint_cover.value; - }; - - void propagate() { - // Incorporate changed elements diff into the count_violations map - for (const auto& element : elements_decremented) { - count_violations[element] -= 1; - } - for (const auto& element : elements_incremented) { - count_violations[element] += 1; - } - // Take out any non-violations - for (auto it = count_violations.begin(); it != count_violations.end();) { - if (it->second == 1) { - it = count_violations.erase(it); - } else { - ++it; - } - } - is_disjoint_cover.value = static_cast(count_violations.size() == 0); - }; - - void commit() { - elements_decremented.clear(); - elements_incremented.clear(); - is_disjoint_cover.old = is_disjoint_cover.value; - }; - - void revert() { - for (const auto& element : elements_decremented) { - // Reverse the decrement - count_violations[element] += 1; - } - for (const auto& element : elements_incremented) { - // Reverse the increment - count_violations[element] -= 1; - } - // We'll leave non-violation entries in count_violations - to be cleared in the next - // propagate - elements_decremented.clear(); - elements_incremented.clear(); - is_disjoint_cover.value = is_disjoint_cover.old; - }; - - // The actual logical output; is_disjoint_cover.value is whether the current state is a disjoint - // cover - Update is_disjoint_cover; - // count_violations[i] = the number of times that element `i` appears in the predecessors, if - // not equal to 1 - std::unordered_map count_violations; - // The "diffs" - lists of elements that were removed (respectively, inserted) in the predecessor - // diffs - std::vector elements_decremented; - std::vector elements_incremented; -}; - -DisjointCoverNode::DisjointCoverNode(ssize_t primary_set_size, std::span node_ptrs) : - ScalarOutputMixin(), primary_set_size_(primary_set_size) { - for (const auto& node : node_ptrs) { - if (!node->integral()) { - throw std::invalid_argument("Predecessors of DisjointCoverNode must be integral"); - } - if (!(node->max() < primary_set_size)) { - throw std::invalid_argument("Predecessor exceeds primary set size"); - } - if (!(node->min() >= 0)) { - throw std::invalid_argument("Predecessor exceeds primary set size"); - } - add_predecessor_(node); - operands_.push_back(node); - } -} - -void DisjointCoverNode::initialize_state(State& state) const { - std::vector count(primary_set_size_, 0); - for (const auto& node : operands_) { - for (const auto& value : node->view(state)) { - ssize_t element = static_cast(value); - // Should not be possible because of checks in constructor - assert(element < primary_set_size_); - assert(element >= 0); - count[element] += 1; - } - } - emplace_data_ptr_(state, count); -} - -void DisjointCoverNode::commit(State& state) const { - data_ptr_(state)->commit(); -} - -void DisjointCoverNode::propagate(State& state) const { - DisjointCoverNodeData* data = data_ptr_(state); - - bool no_update = true; - for (const auto& pred : operands_) { - for (const auto& update : pred->diff(state)) { - no_update = false; - if (!update.placed()) { // i.e. removed or changed. - auto element = static_cast(update.old); - data->elements_decremented.push_back(element); - } - if (!update.removed()) { // i.e. placed or changed. - auto element = static_cast(update.value); - data->elements_incremented.push_back(element); - } - } - } - - // If no update, this node is unchanged - if (no_update) { - return; - } - // Else, propagate the populated diffs to rest of the state - data->propagate(); -} - -void DisjointCoverNode::revert(State& state) const { - data_ptr_(state)->revert(); -} - -double const* DisjointCoverNode::buff(const State& state) const { - return &data_ptr_(state)->is_disjoint_cover.value; -} - -std::span DisjointCoverNode::diff(const State& state) const { - auto data = data_ptr_(state); - return std::span( - &data->is_disjoint_cover, data->is_disjoint_cover.old != data->is_disjoint_cover.value - ); -} - -bool DisjointCoverNode::integral() const { return true; } - -double DisjointCoverNode::min() const { return 0.0; } - -double DisjointCoverNode::max() const { return 1.0; } - } // namespace dwave::optimization diff --git a/dwave/optimization/symbols/__init__.py b/dwave/optimization/symbols/__init__.py index 7036685d2..3ac146176 100644 --- a/dwave/optimization/symbols/__init__.py +++ b/dwave/optimization/symbols/__init__.py @@ -91,7 +91,7 @@ Prod, Sum, ) -from dwave.optimization.symbols.set_routines import IsIn, DisjointCover +from dwave.optimization.symbols.set_routines import IsDisjointCover, IsIn from dwave.optimization.symbols.softmax import SoftMax from dwave.optimization.symbols.sorting import ArgSort from dwave.optimization.symbols.statistics import Mean @@ -133,7 +133,6 @@ "Cos", "DisjointBitSet", "DisjointBitSets", - "DisjointCover", "DisjointList", "DisjointLists", "Divide", @@ -143,6 +142,7 @@ "Extract", "Input", "IntegerVariable", + "IsDisjointCover", "IsIn", "LessEqual", "LinearProgram", diff --git a/dwave/optimization/symbols/set_routines.pyi b/dwave/optimization/symbols/set_routines.pyi index 1224bbfc3..9fed4c727 100644 --- a/dwave/optimization/symbols/set_routines.pyi +++ b/dwave/optimization/symbols/set_routines.pyi @@ -14,6 +14,6 @@ from dwave.optimization.model import ArraySymbol as _ArraySymbol -class IsIn(_ArraySymbol): ... +class IsDisjointCover(_ArraySymbol): ... -class DisjointCover(_ArraySymbol): ... +class IsIn(_ArraySymbol): ... diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index c1e7ed6a4..2a43222bd 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -24,40 +24,15 @@ from libcpp.vector cimport vector from dwave.optimization._model cimport _Graph, _register, ArraySymbol, Symbol from dwave.optimization.libcpp cimport dynamic_cast_ptr from dwave.optimization.libcpp.graph cimport ArrayNode -from dwave.optimization.libcpp.nodes.set_routines cimport IsInNode, DisjointCoverNode +from dwave.optimization.libcpp.nodes.set_routines cimport IsDisjointCoverNode, IsInNode -cdef class IsIn(ArraySymbol): - """Tests which values of one symbol are in another symbol. - - See Also: - :func:`~dwave.optimization.mathematical.isin`: Instantiation and usage - of this symbol. - - :class:`~dwave.optimization.symbols.Extract`, - :class:`~dwave.optimization.symbols.Put`, - :class:`~dwave.optimization.symbols.Where` - - .. versionadded:: 0.6.8 - """ - def __init__(self, ArraySymbol element, ArraySymbol test_elements): - cdef _Graph model = element.model - - if element.model is not test_elements.model: - raise ValueError("element and test_elements do not share the same underlying model") - - cdef IsInNode* ptr = model._graph.emplace_node[IsInNode](element.array_ptr, test_elements.array_ptr) - self.initialize_arraynode(model, ptr) - -_register(IsIn, typeid(IsInNode)) - - -cdef class DisjointCover(ArraySymbol): +cdef class IsDisjointCover(ArraySymbol): """Tests whether the symbols are disjoint, set-like, and cover a set of integers See Also: - .. versionadded:: + .. versionadded:: 0.7.1 """ def __init__(self, Py_ssize_t n, object inputs): if (not isinstance(inputs, collections.abc.Sequence) or @@ -76,15 +51,15 @@ cdef class DisjointCover(ArraySymbol): cppinputs.push_back((symbol).array_ptr) self.primary_set_size = n - cdef DisjointCoverNode* ptr = model._graph.emplace_node[DisjointCoverNode](n, cppinputs) + cdef IsDisjointCoverNode* ptr = model._graph.emplace_node[IsDisjointCoverNode](n, cppinputs) self.initialize_arraynode(model, ptr) @classmethod def _from_symbol(cls, Symbol symbol): - cdef DisjointCoverNode* ptr = dynamic_cast_ptr[DisjointCoverNode](symbol.node_ptr) + cdef IsDisjointCoverNode* ptr = dynamic_cast_ptr[IsDisjointCoverNode](symbol.node_ptr) if not ptr: raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef DisjointCover sym = cls.__new__(cls) + cdef IsDisjointCover sym = cls.__new__(cls) sym.primary_set_size = ptr.primary_set_size() sym.initialize_arraynode(symbol.model, ptr) return sym @@ -107,4 +82,29 @@ cdef class DisjointCover(ArraySymbol): cdef Py_ssize_t primary_set_size -_register(DisjointCover, typeid(DisjointCoverNode)) +_register(IsDisjointCover, typeid(IsDisjointCoverNode)) + + +cdef class IsIn(ArraySymbol): + """Tests which values of one symbol are in another symbol. + + See Also: + :func:`~dwave.optimization.mathematical.isin`: Instantiation and usage + of this symbol. + + :class:`~dwave.optimization.symbols.Extract`, + :class:`~dwave.optimization.symbols.Put`, + :class:`~dwave.optimization.symbols.Where` + + .. versionadded:: 0.6.8 + """ + def __init__(self, ArraySymbol element, ArraySymbol test_elements): + cdef _Graph model = element.model + + if element.model is not test_elements.model: + raise ValueError("element and test_elements do not share the same underlying model") + + cdef IsInNode* ptr = model._graph.emplace_node[IsInNode](element.array_ptr, test_elements.array_ptr) + self.initialize_arraynode(model, ptr) + +_register(IsIn, typeid(IsInNode)) diff --git a/tests/cpp/nodes/test_set_routines.cpp b/tests/cpp/nodes/test_set_routines.cpp index 026670a81..de5a32e0c 100644 --- a/tests/cpp/nodes/test_set_routines.cpp +++ b/tests/cpp/nodes/test_set_routines.cpp @@ -28,6 +28,230 @@ using Catch::Matchers::RangeEquals; namespace dwave::optimization { +TEST_CASE("IsDisjointCoverNode") { + auto graph = Graph(); + + GIVEN("Two constant nodes that are disjoint") { + auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2}); + auto c2_ptr = graph.emplace_node(std::vector{3, 4}); + std::vector sets{c1_ptr, c2_ptr}; + + THEN("We can construct a IsDisjointCover node") { + auto dc_ptr = graph.emplace_node(5, sets); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + } + } + THEN("We can construct a IsDisjointCover node with a larger primary set size") { + auto dc_ptr = graph.emplace_node(6, sets); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + THEN("We cannot construct a IsDisjointCover node with a smaller primary set size") { + CHECK_THROWS(graph.emplace_node(4, sets)); + } + } + + GIVEN("Two constant nodes that are disjoint but not sets") { + auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2, 2}); + auto c2_ptr = graph.emplace_node(std::vector{3, 4}); + std::vector sets{c1_ptr, c2_ptr}; + + THEN("We can construct a IsDisjointCover node") { + auto dc_ptr = graph.emplace_node(5, sets); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state") { + auto state = graph.initialize_state(); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + + GIVEN("Two set nodes") { + auto set1_ptr = graph.emplace_node(4); + auto set2_ptr = graph.emplace_node(5); + std::vector sets{set1_ptr, set2_ptr}; + + THEN("We can construct a IsDisjointCover node") { + auto dc_ptr = graph.emplace_node(5, sets); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state with disjoint sets that cover the primary set") { + auto state = graph.initialize_state(); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{3, 4}); + graph.propagate(state); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{3}); + graph.propagate(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + } + } + } + AND_WHEN("We initialize a state with not-disjoint sets") { + auto state = graph.initialize_state(); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{2, 3, 4}); + graph.propagate(state); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + set1_ptr->assign(state, std::vector{3, 1, 2}); + set2_ptr->assign(state, std::vector{4, 0}); + graph.propagate(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + AND_WHEN("We initialize a state with disjoint sets that do not cover the primary set") { + auto state = graph.initialize_state(); + set1_ptr->assign(state, std::vector{0, 1, 2}); + set2_ptr->assign(state, std::vector{3}); + graph.propagate(state); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + set1_ptr->assign(state, std::vector{0, 1, 3}); + set2_ptr->assign(state, std::vector{2, 4}); + graph.propagate(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + } + } + + GIVEN("Three list nodes") { + auto list1_ptr = graph.emplace_node(5, 0, 5); + auto list2_ptr = graph.emplace_node(5, 0, 5); + auto list3_ptr = graph.emplace_node(5, 0, 5); + std::vector lists{list1_ptr, list2_ptr, list3_ptr}; + + THEN("We can construct a IsDisjointCover node") { + auto dc_ptr = graph.emplace_node(5, lists); + graph.emplace_node(dc_ptr); + + CHECK(dc_ptr->min() == 0.0); + CHECK(dc_ptr->max() == 1.0); + CHECK(dc_ptr->integral()); + CHECK(dc_ptr->logical()); + + AND_WHEN("We initialize a state with disjoint lists that cover the primary set") { + auto state = graph.initialize_state(); + list1_ptr->assign(state, std::vector{0, 1, 2}); + list2_ptr->assign(state, std::vector{3, 4}); + list3_ptr->assign(state, std::vector{}); + graph.propagate(state); + + THEN("The initial IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + + AND_WHEN("We commit and change the state") { + graph.commit(state); + list1_ptr->exchange(state, 0, 2); + graph.propagate(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } + } + AND_WHEN("We commit and change the state") { + graph.commit(state); + list1_ptr->shrink(state); + graph.propagate(state); + + THEN("The IsDisjointCover state is correct") { + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } + } + } + } + } +} + TEST_CASE("IsInNode") { auto graph = Graph(); @@ -267,228 +491,4 @@ TEST_CASE("IsInNode") { } } -TEST_CASE("DisjointCoverNode") { - auto graph = Graph(); - - GIVEN("Two constant nodes that are disjoint") { - auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2}); - auto c2_ptr = graph.emplace_node(std::vector{3, 4}); - std::vector sets{c1_ptr, c2_ptr}; - - THEN("We can construct a DisjointCover node") { - auto dc_ptr = graph.emplace_node(5, sets); - graph.emplace_node(dc_ptr); - - CHECK(dc_ptr->min() == 0.0); - CHECK(dc_ptr->max() == 1.0); - CHECK(dc_ptr->integral()); - CHECK(dc_ptr->logical()); - - AND_WHEN("We initialize a state") { - auto state = graph.initialize_state(); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - } - } - THEN("We can construct a DisjointCover node with a larger primary set size") { - auto dc_ptr = graph.emplace_node(6, sets); - graph.emplace_node(dc_ptr); - - CHECK(dc_ptr->min() == 0.0); - CHECK(dc_ptr->max() == 1.0); - CHECK(dc_ptr->integral()); - CHECK(dc_ptr->logical()); - - AND_WHEN("We initialize a state") { - auto state = graph.initialize_state(); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - } - } - THEN("We cannot construct a DisjointCover node with a smaller primary set size") { - CHECK_THROWS(graph.emplace_node(4, sets)); - } - } - - GIVEN("Two constant nodes that are disjoint but not sets") { - auto c1_ptr = graph.emplace_node(std::vector{0, 1, 2, 2}); - auto c2_ptr = graph.emplace_node(std::vector{3, 4}); - std::vector sets{c1_ptr, c2_ptr}; - - THEN("We can construct a DisjointCover node") { - auto dc_ptr = graph.emplace_node(5, sets); - graph.emplace_node(dc_ptr); - - CHECK(dc_ptr->min() == 0.0); - CHECK(dc_ptr->max() == 1.0); - CHECK(dc_ptr->integral()); - CHECK(dc_ptr->logical()); - - AND_WHEN("We initialize a state") { - auto state = graph.initialize_state(); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - } - } - } - - GIVEN("Two set nodes") { - auto set1_ptr = graph.emplace_node(4); - auto set2_ptr = graph.emplace_node(5); - std::vector sets{set1_ptr, set2_ptr}; - - THEN("We can construct a DisjointCover node") { - auto dc_ptr = graph.emplace_node(5, sets); - graph.emplace_node(dc_ptr); - - CHECK(dc_ptr->min() == 0.0); - CHECK(dc_ptr->max() == 1.0); - CHECK(dc_ptr->integral()); - CHECK(dc_ptr->logical()); - - AND_WHEN("We initialize a state with disjoint sets that cover the primary set") { - auto state = graph.initialize_state(); - set1_ptr->assign(state, std::vector{0, 1, 2}); - set2_ptr->assign(state, std::vector{3, 4}); - graph.propagate(state); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - - AND_WHEN("We commit and change the state") { - graph.commit(state); - set1_ptr->assign(state, std::vector{0, 1, 2}); - set2_ptr->assign(state, std::vector{3}); - graph.propagate(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - - AND_WHEN("We revert") { - graph.revert(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - } - } - } - AND_WHEN("We initialize a state with not-disjoint sets") { - auto state = graph.initialize_state(); - set1_ptr->assign(state, std::vector{0, 1, 2}); - set2_ptr->assign(state, std::vector{2, 3, 4}); - graph.propagate(state); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - - AND_WHEN("We commit and change the state") { - graph.commit(state); - set1_ptr->assign(state, std::vector{3, 1, 2}); - set2_ptr->assign(state, std::vector{4, 0}); - graph.propagate(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - - AND_WHEN("We revert") { - graph.revert(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - } - } - } - AND_WHEN("We initialize a state with disjoint sets that do not cover the primary set") { - auto state = graph.initialize_state(); - set1_ptr->assign(state, std::vector{0, 1, 2}); - set2_ptr->assign(state, std::vector{3}); - graph.propagate(state); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - - AND_WHEN("We commit and change the state") { - graph.commit(state); - set1_ptr->assign(state, std::vector{0, 1, 3}); - set2_ptr->assign(state, std::vector{2, 4}); - graph.propagate(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - - AND_WHEN("We revert") { - graph.revert(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - } - } - } - } - } - - GIVEN("Three list nodes") { - auto list1_ptr = graph.emplace_node(5, 0, 5); - auto list2_ptr = graph.emplace_node(5, 0, 5); - auto list3_ptr = graph.emplace_node(5, 0, 5); - std::vector lists{list1_ptr, list2_ptr, list3_ptr}; - - THEN("We can construct a DisjointCover node") { - auto dc_ptr = graph.emplace_node(5, lists); - graph.emplace_node(dc_ptr); - - CHECK(dc_ptr->min() == 0.0); - CHECK(dc_ptr->max() == 1.0); - CHECK(dc_ptr->integral()); - CHECK(dc_ptr->logical()); - - AND_WHEN("We initialize a state with disjoint lists that cover the primary set") { - auto state = graph.initialize_state(); - list1_ptr->assign(state, std::vector{0, 1, 2}); - list2_ptr->assign(state, std::vector{3, 4}); - list3_ptr->assign(state, std::vector{}); - graph.propagate(state); - - THEN("The initial DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - - AND_WHEN("We commit and change the state") { - graph.commit(state); - list1_ptr->exchange(state, 0, 2); - graph.propagate(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); - } - } - AND_WHEN("We commit and change the state") { - graph.commit(state); - list1_ptr->shrink(state); - graph.propagate(state); - - THEN("The DisjointCover state is correct") { - CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); - } - } - } - } - } -} - } // namespace dwave::optimization diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 9343971bb..ee49f579d 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1504,20 +1504,20 @@ def generate_symbols(self): model.constant([0, 1, 2]), model.constant([3, 4]) ] - cover = dwave.optimization.symbols.DisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(5, sets) with model.lock(): yield cover def test(self): - from dwave.optimization.symbols import DisjointCover + from dwave.optimization.symbols import IsDisjointCover model = Model() sets = [ model.constant([0, 1, 2]), model.constant([3, 4]) ] - cover = dwave.optimization.symbols.DisjointCover(5, sets) - self.assertIsInstance(cover, DisjointCover) + cover = dwave.optimization.symbols.IsDisjointCover(5, sets) + self.assertIsInstance(cover, IsDisjointCover) def test_state(self): model = Model() @@ -1526,7 +1526,7 @@ def test_state(self): model.constant([0, 1, 2]), model.constant([3, 4]) ] - cover = dwave.optimization.symbols.DisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(5, sets) model.states.resize(1) with model.lock(): expected = np.array([1.0]) @@ -1537,142 +1537,12 @@ def test_state(self): model.constant([0, 1, 2]), model.constant([2, 3, 4]) ] - cover = dwave.optimization.symbols.DisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(5, sets) with model.lock(): expected = np.array([0.0]) np.testing.assert_array_almost_equal(cover.state(0), expected) -# Strictly speaking, not a symbol, but thematically it makes sense here -class TestDisjointListsContainer(utils.SymbolTests): - def test_inequality(self): - # TODO re-enable this once equality has been fixed - pass - - def generate_symbols(self): - model = Model() - d = model.disjoint_lists_container(10, 4) - model.lock() - yield from d - - def test(self): - model = Model() - - dls = model.disjoint_lists_container(10, 4) - - self.assertEqual(dls.primary_set_size(), 10) - self.assertEqual(dls.num_disjoint_lists(), 4) - - def test_indexing(self): - model = Model() - - dls = model.disjoint_lists_container(10, 4) - - self.assertEqual(len(list(dls)), 4) - self.assertIsInstance(dls[0], dwave.optimization.symbols.ListVariable) - self.assertIsInstance(dls[3], dwave.optimization.symbols.ListVariable) - - with self.assertRaises(IndexError): - dls[4] - - def test_construction(self): - model = Model() - - with self.assertRaises(ValueError): - model.disjoint_lists_container(-5, 1) - with self.assertRaises(ValueError): - model.disjoint_lists_container(1, -5) - with self.assertRaises(ValueError): - model.disjoint_lists_container(0, 1) - - def test_num_returned_nodes(self): - model = Model() - - model.disjoint_lists_container(10, 4) - - # One node for each of the 4 successor lists, plus a DisjointCover node - self.assertEqual(model.num_nodes(), 5) - - def test_set_state(self): - with self.subTest("array-like output lists"): - model = Model() - model.states.resize(1) - x = model.disjoint_lists_container(5, 3) - model.lock() - - x.set_state(0, [[0, 1], [2, 3], [4]]) - - np.testing.assert_array_equal(x[0].state(), [0, 1]) - np.testing.assert_array_equal(x[1].state(), [2, 3]) - np.testing.assert_array_equal(x[2].state(), [4]) - - with self.subTest("invalid state index"): - model = Model() - x = model.disjoint_lists_container(5, 3) - - state = [[0, 1, 2, 3, 4], [], []] - - # No states have been created - with self.assertRaisesRegex(ValueError, r"^index out of range: 0$"): - x.set_state(0, state) - with self.assertRaisesRegex(ValueError, r"^index out of range: -1$"): - x.set_state(-1, state) - - # Some states have been created - model.states.resize(5) - with self.assertRaisesRegex(ValueError, r"^index out of range: 5$"): - x.set_state(5, state) - with self.assertRaisesRegex(ValueError, r"^index out of range: -1$"): - x.set_state(-1, state) - - with self.subTest("non-integer"): - # gets translated into integer according to NumPy rules - model = Model() - model.states.resize(1) - x = model.disjoint_lists_container(5, 3) - model.lock() - - x.set_state(0, [[4.5, 3, 2, 1, 0], [], []]) - np.testing.assert_array_equal(x[0].state(), [4, 3, 2, 1, 0]) - - with self.subTest("invalid"): - model = Model() - model.states.resize(1) - x = model.disjoint_lists_container(5, 3) - model.lock() - - with self.assertRaisesRegex( - ValueError, r"^values must be a subset of range\(5\)$" - ): - x.set_state(0, [[0, 0, 1, 2, 3], [], []]) - - # wrong number of lists - with self.assertRaises(IndexError): - x.set_state(0, [[0, 1, 2, 3, 4], [], [], []]) - - with self.subTest("infeasible"): - model = Model() - model.states.resize(1) - x = model.disjoint_lists_container(5, 3) - model.lock() - - # Not disjoint - x.set_state(0, [[0, 1, 2, 3], [3], [4]]) - self.assertFalse(model.feasible(0)) - - # Not a cover - x.set_state(0, [[0, 1, 2], [], [4]]) - self.assertFalse(model.feasible(0)) - - def test_state_size(self): - model = Model() - - d = model.disjoint_lists_container(10, 4) - - for s in d: - self.assertEqual(s.state_size(), 10 * 8) - - class TestDisjointListsVariable(utils.SymbolTests): def test_inequality(self): # TODO re-enable this once equality has been fixed From a364fab9ee62c5554b4d3ddd1a63598803bb1f53 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Mon, 8 Jun 2026 10:36:35 -0400 Subject: [PATCH 06/13] Finish a docstring --- dwave/optimization/mathematical.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index afbc92a75..517413502 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1123,7 +1123,7 @@ def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDi subsets: List of array symbols to test whether they are disjoint and cover the primary set Returns: - A scalar boolean-valued array symbol indicating whether the subsets + A scalar boolean-valued array symbol indicating whether the subsets are a disjoint cover Examples: >>> from dwave.optimization.model import Model From db0efc7fbe5cf9c47c9722ddc78bc2f04dd92f7a Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Wed, 10 Jun 2026 11:46:38 -0400 Subject: [PATCH 07/13] Fix doctests --- dwave/optimization/mathematical.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 517413502..2f4d3b8cc 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1132,13 +1132,13 @@ def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDi >>> model = Model() >>> model.states.resize(1) >>> subsets = [] - >>> subsets.append(model.constant([0, 1]) - >>> subsets.append(model.constant([2, 3, 4]) - >>> subsets.append(model.constant([]) + >>> subsets.append(model.constant([0, 1])) + >>> subsets.append(model.constant([2, 3, 4])) + >>> subsets.append(model.constant([])) >>> is_disjoint = is_disjoint_cover(5, subsets) >>> with model.lock(): ... print(is_disjoint.state(0)) - [1.] + 1.0 See Also: :class:`~dwave.optimization.symbols.IsDisjointCover`: Generated symbol From 9d16e748efd564830e710bc3a7a5d0550361f428 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Thu, 25 Jun 2026 13:14:43 -0400 Subject: [PATCH 08/13] Format deprecation message better --- dwave/optimization/model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index b3c044f7c..350132f56 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -593,10 +593,10 @@ def disjoint_lists_symbol( """ warnings.warn( "The use of Model.disjoint_lists_symbol() is deprecated " - "since dwave.optimization 0.7.1. Use " - "`from dwave.optimization.mathematical import is_disjoint_cover` " - "`lists = [model.list(primary_set_size, min_size=0) for _ in range(num_disjoint_lists)]` " - "`model.add_constraint(is_disjoint_cover(primary_set_size, lists))`", + "since dwave.optimization 0.7.1. Use\n" + "from dwave.optimization.mathematical import is_disjoint_cover\n" + "lists = [model.list(primary_set_size, min_size=0) for _ in range(num_disjoint_lists)]\n" + "model.add_constraint(is_disjoint_cover(primary_set_size, lists))", DeprecationWarning, ) From 28f5afa95e7e056c3e45cd26bfa86b6e47bdb87d Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Wed, 15 Jul 2026 17:31:18 -0400 Subject: [PATCH 09/13] Update version numbers --- dwave/optimization/mathematical.py | 2 +- dwave/optimization/model.py | 2 +- dwave/optimization/symbols/set_routines.pyx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 2f4d3b8cc..5c782de03 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1145,7 +1145,7 @@ def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDi :func:`.isin` - .. versionadded:: 0.7.1 + .. versionadded:: 0.7.2 """ return IsDisjointCover(primary_set_size, subsets) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 350132f56..7bdcc85b1 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -593,7 +593,7 @@ def disjoint_lists_symbol( """ warnings.warn( "The use of Model.disjoint_lists_symbol() is deprecated " - "since dwave.optimization 0.7.1. Use\n" + "since dwave.optimization 0.7.2. Use\n" "from dwave.optimization.mathematical import is_disjoint_cover\n" "lists = [model.list(primary_set_size, min_size=0) for _ in range(num_disjoint_lists)]\n" "model.add_constraint(is_disjoint_cover(primary_set_size, lists))", diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index 2a43222bd..3bd77aad4 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -32,7 +32,7 @@ cdef class IsDisjointCover(ArraySymbol): See Also: - .. versionadded:: 0.7.1 + .. versionadded:: 0.7.2 """ def __init__(self, Py_ssize_t n, object inputs): if (not isinstance(inputs, collections.abc.Sequence) or From 36b3de2093a71e18262ce003f1689e620a717360 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Wed, 15 Jul 2026 19:51:23 -0400 Subject: [PATCH 10/13] Update generators and tests to avoid deprecated symbol --- dwave/optimization/generators.py | 12 +++--- tests/test_generators.py | 66 +++++++++++++++++--------------- tests/test_model.py | 2 + tests/test_symbols.py | 2 +- 4 files changed, 44 insertions(+), 38 deletions(-) diff --git a/dwave/optimization/generators.py b/dwave/optimization/generators.py index e95d96245..b8805d10c 100644 --- a/dwave/optimization/generators.py +++ b/dwave/optimization/generators.py @@ -34,6 +34,7 @@ concatenate, exp, expit, + is_disjoint_cover, logical_or, maximum, minimum, @@ -511,10 +512,8 @@ class as the decision variable being optimized, with permutations of its demand = model.constant(customer_demand) capacity = model.constant(vehicle_capacity) - # Add the decision variable - routes = model.disjoint_lists_symbol( - primary_set_size=num_customers, - num_disjoint_lists=number_of_vehicles) + routes = [model.list(num_customers, min_size=0) for _ in range(number_of_vehicles)] + model.add_constraint(is_disjoint_cover(num_customers, routes)) # The objective is to minimize the distance traveled. # This is calculated by adding the distance from the depot to the 1st customer @@ -757,9 +756,8 @@ class as the decision variable being optimized, with permutations of its one = model.constant(1) # Add the decision variable - routes = model.disjoint_lists_symbol( - primary_set_size=num_customers, - num_disjoint_lists=number_of_vehicles) + routes = [model.list(num_customers, min_size=0) for _ in range(number_of_vehicles)] + model.add_constraint(is_disjoint_cover(num_customers, routes)) # Capacity constraint capacity_constraints = [(demand[routes[vehicle_idx]].sum() <= capacity) diff --git a/tests/test_generators.py b/tests/test_generators.py index b24864ad2..7d37f2e39 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -269,16 +269,17 @@ def test_basics(self): locations_y=[2, 0], depot_x_y=[0, 0]) - self.assertEqual(model.num_decisions(), 1) - self.assertEqual(model.num_constraints(), num_vehicles) + self.assertEqual(model.num_decisions(), 2) + self.assertEqual(model.num_constraints(), num_vehicles+1) self.assertEqual(model.num_nodes(), 34) self.assertEqual(model.num_edges(), 46) self.assertEqual(model.is_locked(), True) model.states.resize(1) - route = next(model.iter_decisions()) - self.assertEqual(route.num_disjoint_lists(), 2) - route.set_state(0, [[0], [1]]) + routes = list(model.iter_decisions()) + self.assertEqual(len(routes), 2) + routes[0].set_state(0, [0]) + routes[1].set_state(0, [1]) self.assertEqual(model.objective.state(0), 10) # Depot at (0, 1.5) from demand[0] == 0 @@ -290,9 +291,10 @@ def test_basics(self): locations_y=[1.5, 1.5]) model.states.resize(1) - route = next(model.iter_decisions()) - self.assertEqual(route.num_disjoint_lists(), 2) - route.set_state(0, [[0], []]) + routes = list(model.iter_decisions()) + self.assertEqual(len(routes), 2) + routes[0].set_state(0, [0]) + routes[1].set_state(0, []) self.assertEqual(model.objective.state(0), 6) # Depot at (3, 2) from depot_x_y @@ -305,9 +307,10 @@ def test_basics(self): depot_x_y=[3, 2]) model.states.resize(1) - route = next(model.iter_decisions()) - self.assertEqual(route.num_disjoint_lists(), 2) - route.set_state(0, [[0], [1]]) + routes = list(model.iter_decisions()) + self.assertEqual(len(routes), 2) + routes[0].set_state(0, [0]) + routes[1].set_state(0, [1]) self.assertEqual(model.objective.state(0), 10) # Test asymmetric distances @@ -318,9 +321,10 @@ def test_basics(self): distances=[[0, 1, 3], [2, 0, np.sqrt(17)], [4, np.sqrt(13), 0]]) model.states.resize(1) - route = next(model.iter_decisions()) - self.assertEqual(route.num_disjoint_lists(), 2) - route.set_state(0, [[0], [1]]) + routes = list(model.iter_decisions()) + self.assertEqual(len(routes), 2) + routes[0].set_state(0, [0]) + routes[1].set_state(0, [1]) self.assertEqual(model.objective.state(0), 10) def test_cvrplib_P_n19_k2(self): @@ -333,9 +337,9 @@ def test_cvrplib_P_n19_k2(self): vehicle_capacity=160) model.states.resize(1) - route = next(model.iter_decisions()) - route.set_state(0, [[i - 1 for i in [4, 11, 14, 12, 3, 17, 16, 8, 6]], - [i - 1 for i in [18, 5, 13, 15, 9, 7, 2, 10, 1]]]) + routes = list(model.iter_decisions()) + routes[0].set_state(0, [i - 1 for i in [4, 11, 14, 12, 3, 17, 16, 8, 6]]) + routes[1].set_state(0, [i - 1 for i in [18, 5, 13, 15, 9, 7, 2, 10, 1]]) self.assertGreater(model.objective.state(0), 212) self.assertLess(model.objective.state(0), 213) @@ -366,10 +370,10 @@ def test_state_serialization(self): model.states.resize(1) - routes, = model.iter_decisions() + routes = list(model.iter_decisions()) - routes.set_state(0, [[i - 1 for i in [4, 11, 14, 12, 3, 17, 16, 8, 6]], - [i - 1 for i in [18, 5, 13, 15, 9, 7, 2, 10, 1]]]) + routes[0].set_state(0, [i - 1 for i in [4, 11, 14, 12, 3, 17, 16, 8, 6]]) + routes[1].set_state(0, [i - 1 for i in [18, 5, 13, 15, 9, 7, 2, 10, 1]]) # just smoke test with model.states.to_file() as f: @@ -526,14 +530,15 @@ def test_basics(self): service_time=[0, 0, 0]) min_expected_number_of_constraints = num_vehicles * 2 + n_time_windows + n_customers - self.assertEqual(model.num_decisions(), 1) + self.assertEqual(model.num_decisions(), 2) self.assertGreaterEqual(model.num_constraints(),min_expected_number_of_constraints) self.assertEqual(model.is_locked(), True) model.states.resize(1) - route = next(model.iter_decisions()) - self.assertEqual(route.num_disjoint_lists(), 2) - route.set_state(0, [[0], [1]]) + routes = list(model.iter_decisions()) + self.assertEqual(len(routes), 2) + routes[0].set_state(0, [0]) + routes[1].set_state(0, [1]) self.assertEqual(model.objective.state(0), 15) # Test asymmetric distances @@ -548,9 +553,10 @@ def test_basics(self): ) model.states.resize(1) - route = next(model.iter_decisions()) - self.assertEqual(route.num_disjoint_lists(), 2) - route.set_state(0, [[0], [1]]) + routes = list(model.iter_decisions()) + self.assertEqual(len(routes), 2) + routes[0].set_state(0, [0]) + routes[1].set_state(0, [1]) self.assertEqual(model.objective.state(0), 10) def test_serialization(self): @@ -600,10 +606,10 @@ def test_state_serialization(self): model.states.resize(1) - routes, = model.iter_decisions() + routes = list(model.iter_decisions()) - routes.set_state(0, [[i-1 for i in [1, 2]], - [i-1 for i in [3]]]) + routes[0].set_state(0, [i-1 for i in [1, 2]]) + routes[1].set_state(0, [i-1 for i in [3]]) # just smoke test with model.states.to_file() as f: diff --git a/tests/test_model.py b/tests/test_model.py index 41de0f34b..62f898bf2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -379,7 +379,9 @@ def test_remove_unused_symbols(self): self.assertEqual(num_removed, 2) self.assertEqual(model.num_symbols(), 1) + with self.subTest("disjoint lists"): + self.skipTest("Deprecated symbol") model = Model() disjoint_lists = model.disjoint_lists_symbol(10, 4) diff --git a/tests/test_symbols.py b/tests/test_symbols.py index ee49f579d..18cbe7ee2 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1542,7 +1542,7 @@ def test_state(self): expected = np.array([0.0]) np.testing.assert_array_almost_equal(cover.state(0), expected) - +@unittest.skip("Deprecated symbol") class TestDisjointListsVariable(utils.SymbolTests): def test_inequality(self): # TODO re-enable this once equality has been fixed From eed5066c96f0a6c1732189468772291f4f012e72 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Wed, 15 Jul 2026 19:55:42 -0400 Subject: [PATCH 11/13] Add release note --- .../notes/is-disjoint-cover-9f6bd97b637511fc.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml diff --git a/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml b/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml new file mode 100644 index 000000000..c66156903 --- /dev/null +++ b/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml @@ -0,0 +1,11 @@ +--- +features: + - | + Add C++ ``IsDisjointCoverNode`` node/symbol, which computes whether + a collection of lists are disjoint and cover some primary set. + - Add ``IsDisjointCover`` symbol and `is_disjoint_cover` function. + +deprecations: + - | + Deprecates `disjoint_list_symbol` + From 9ab666459c707f446c6445c66f36c89a2febd6c1 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Thu, 16 Jul 2026 09:00:49 -0400 Subject: [PATCH 12/13] Fix doctests and include set_routines.hpp to nodes.hpp --- dwave/optimization/generators.py | 12 +++++++----- .../include/dwave-optimization/nodes.hpp | 1 + 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dwave/optimization/generators.py b/dwave/optimization/generators.py index b8805d10c..8d39fe965 100644 --- a/dwave/optimization/generators.py +++ b/dwave/optimization/generators.py @@ -378,7 +378,7 @@ class as the decision variable being optimized, with permutations of its The :meth:`~dwave.optimization.model.Model.iter_decisions` method obtains the decision variables of the generated model. - >>> routes = next(model.iter_decisions()) + >>> routes = list(model.iter_decisions()) To test the solution above, set it in the model as the state of the decision variable. **Skip these next lines** if you have submitted your @@ -386,7 +386,8 @@ class as the decision variable being optimized, with permutations of its nonlinear :term:`solver`. >>> model.states.resize(1) - >>> routes.set_state(0, [[2., 7., 1., 5.], [4., 3., 8., 6., 0.]]) + >>> routes[0].set_state(0, [2., 7., 1., 5.]) + >>> routes[1].set_state(0, [4., 3., 8., 6., 0.]) You can use the :meth:`~dwave.optimization.model.Model.iter_constraints` method to check feasibility of constructed or returned solutions. Here, @@ -399,7 +400,7 @@ class as the decision variable being optimized, with permutations of its ... for i in range(model.states.size()): ... if capacity_constraint.state(i): # Filter on feasibility ... print((f"Objective value #{i} is {model.objective.state(i).round(2)} for routes\n" - ... f" {[r.state(i).tolist() for r in routes.iter_successors()]}")) + ... f" {[r.state(i).tolist() for r in routes]}")) Objective value #0 is 423.8 for routes [[2.0, 7.0, 1.0, 5.0], [4.0, 3.0, 8.0, 6.0, 0.0]] """ @@ -630,7 +631,7 @@ class as the decision variable being optimized, with permutations of its The :meth:`~dwave.optimization.model.Model.iter_decisions` method obtains the decision variables of the generated model. - >>> routes = next(model.iter_decisions()) + >>> routes = list(model.iter_decisions()) To test the solution above, set it in the model as the state of the decision variable. **Skip these next lines** if you have submitted your @@ -638,7 +639,8 @@ class as the decision variable being optimized, with permutations of its nonlinear :term:`solver`. >>> model.states.resize(1) - >>> routes.set_state(0, [[0, 2], [1]]) + >>> routes[0].set_state(0, [0, 2]) + >>> routes[1].set_state(0, [1]) You can use the :meth:`~dwave.optimization.model.Model.iter_constraints` method to check feasibility of constructed or returned solutions. Here, diff --git a/dwave/optimization/include/dwave-optimization/nodes.hpp b/dwave/optimization/include/dwave-optimization/nodes.hpp index 01efa8b5a..7ebfa47bc 100644 --- a/dwave/optimization/include/dwave-optimization/nodes.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes.hpp @@ -28,5 +28,6 @@ #include "dwave-optimization/nodes/numbers.hpp" #include "dwave-optimization/nodes/quadratic_model.hpp" #include "dwave-optimization/nodes/reduce.hpp" +#include "dwave-optimization/nodes/set_routines.hpp" #include "dwave-optimization/nodes/testing.hpp" #include "dwave-optimization/nodes/unaryop.hpp" From 81dc6704b52a9174c651bb1638be463df95aec06 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Thu, 16 Jul 2026 15:51:51 -0400 Subject: [PATCH 13/13] Change constructor argument order Allow primary_set_size to be optional on python side --- dwave/optimization/generators.py | 4 ++-- .../dwave-optimization/nodes/set_routines.hpp | 2 +- dwave/optimization/mathematical.py | 18 +++++++++++++----- dwave/optimization/src/nodes/set_routines.cpp | 4 ++-- dwave/optimization/symbols/set_routines.pyx | 6 +++--- tests/cpp/nodes/test_set_routines.cpp | 12 ++++++------ tests/test_symbols.py | 8 ++++---- 7 files changed, 31 insertions(+), 23 deletions(-) diff --git a/dwave/optimization/generators.py b/dwave/optimization/generators.py index 8d39fe965..bfbb4d9a5 100644 --- a/dwave/optimization/generators.py +++ b/dwave/optimization/generators.py @@ -514,7 +514,7 @@ class as the decision variable being optimized, with permutations of its capacity = model.constant(vehicle_capacity) routes = [model.list(num_customers, min_size=0) for _ in range(number_of_vehicles)] - model.add_constraint(is_disjoint_cover(num_customers, routes)) + model.add_constraint(is_disjoint_cover(routes)) # The objective is to minimize the distance traveled. # This is calculated by adding the distance from the depot to the 1st customer @@ -759,7 +759,7 @@ class as the decision variable being optimized, with permutations of its # Add the decision variable routes = [model.list(num_customers, min_size=0) for _ in range(number_of_vehicles)] - model.add_constraint(is_disjoint_cover(num_customers, routes)) + model.add_constraint(is_disjoint_cover(routes)) # Capacity constraint capacity_constraints = [(demand[routes[vehicle_idx]].sum() <= capacity) diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index 78210a195..e4272609e 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -24,7 +24,7 @@ namespace dwave::optimization { class IsDisjointCoverNode : public ScalarOutputMixin { public: - IsDisjointCoverNode(ssize_t primary_set_size, std::span node_ptrs); + IsDisjointCoverNode(std::span node_ptrs, ssize_t primary_set_size); /// @copydoc Array::buff() double const* buff(const State& state) const override; diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 5c782de03..7ead06df0 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1112,15 +1112,16 @@ def hstack(arrays: collections.abc.Sequence[ArraySymbol]) -> ArraySymbol: return concatenate(arrays, 1) -def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDisjointCover: +def is_disjoint_cover(subsets: list[ArraySymbol], *, primary_set_size: int|None = None) -> IsDisjointCover: """Return whether the symbols are disjoint, set-like, and cover a set of integers. Determines whether a collection of array symbols is disjoint and the union equals a fixed set. Args: - primary_set_size: Number of elements in the primary set: {0, 1, ..., primary_set_size - 1}. - Must be non-negative. subsets: List of array symbols to test whether they are disjoint and cover the primary set + primary_set_size: Number of elements in the primary set: {0, 1, ..., primary_set_size - 1}. + Must be non-negative. If `None`, primary set size is inferred from the common, finite, + nonnegative maximum value of the arrays. Returns: A scalar boolean-valued array symbol indicating whether the subsets are a disjoint cover @@ -1135,7 +1136,7 @@ def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDi >>> subsets.append(model.constant([0, 1])) >>> subsets.append(model.constant([2, 3, 4])) >>> subsets.append(model.constant([])) - >>> is_disjoint = is_disjoint_cover(5, subsets) + >>> is_disjoint = is_disjoint_cover(subsets, primary_set_size=5) >>> with model.lock(): ... print(is_disjoint.state(0)) 1.0 @@ -1147,7 +1148,14 @@ def is_disjoint_cover(primary_set_size: int, subsets: list[ArraySymbol]) -> IsDi .. versionadded:: 0.7.2 """ - return IsDisjointCover(primary_set_size, subsets) + if primary_set_size is None: + primary_set_size = int(subsets[0].info().max) + 1 + if not np.isfinite(primary_set_size) or primary_set_size <= 0: + raise ValueError("Cannot infer primary set size from subsets") + for subset in subsets[1:]: + if int(subset.info().max) != primary_set_size - 1: + raise ValueError("Cannot infer primary set size from subsets") + return IsDisjointCover(subsets, primary_set_size) def isin(element: ArraySymbol, test_elements: ArraySymbol) -> IsIn: diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index 81e90cc53..c6ca5fd9c 100644 --- a/dwave/optimization/src/nodes/set_routines.cpp +++ b/dwave/optimization/src/nodes/set_routines.cpp @@ -112,8 +112,8 @@ struct IsDisjointCoverNodeData : public NodeStateData { }; IsDisjointCoverNode::IsDisjointCoverNode( - ssize_t primary_set_size, - std::span node_ptrs + std::span node_ptrs, + ssize_t primary_set_size ) : ScalarOutputMixin(), primary_set_size_(primary_set_size) { for (const auto& node : node_ptrs) { diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index 3bd77aad4..3342969e2 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -34,7 +34,7 @@ cdef class IsDisjointCover(ArraySymbol): .. versionadded:: 0.7.2 """ - def __init__(self, Py_ssize_t n, object inputs): + def __init__(self, object inputs, Py_ssize_t n): if (not isinstance(inputs, collections.abc.Sequence) or not all(isinstance(arr, ArraySymbol) for arr in inputs)): raise TypeError("disjoint_cover takes a sequence of array symbols") @@ -51,7 +51,7 @@ cdef class IsDisjointCover(ArraySymbol): cppinputs.push_back((symbol).array_ptr) self.primary_set_size = n - cdef IsDisjointCoverNode* ptr = model._graph.emplace_node[IsDisjointCoverNode](n, cppinputs) + cdef IsDisjointCoverNode* ptr = model._graph.emplace_node[IsDisjointCoverNode](cppinputs, n) self.initialize_arraynode(model, ptr) @classmethod @@ -68,7 +68,7 @@ cdef class IsDisjointCover(ArraySymbol): def _from_zipfile(cls, zf, directory, _Graph model, predecessors): with zf.open(directory + "args.json", "r") as f: args = json.load(f) - return cls(args["primary_set_size"], list(predecessors)) + return cls(list(predecessors), args["primary_set_size"]) def _into_zipfile(self, zf, directory): super()._into_zipfile(zf, directory) diff --git a/tests/cpp/nodes/test_set_routines.cpp b/tests/cpp/nodes/test_set_routines.cpp index de5a32e0c..93c1bb7b6 100644 --- a/tests/cpp/nodes/test_set_routines.cpp +++ b/tests/cpp/nodes/test_set_routines.cpp @@ -37,7 +37,7 @@ TEST_CASE("IsDisjointCoverNode") { std::vector sets{c1_ptr, c2_ptr}; THEN("We can construct a IsDisjointCover node") { - auto dc_ptr = graph.emplace_node(5, sets); + auto dc_ptr = graph.emplace_node(sets, 5); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -54,7 +54,7 @@ TEST_CASE("IsDisjointCoverNode") { } } THEN("We can construct a IsDisjointCover node with a larger primary set size") { - auto dc_ptr = graph.emplace_node(6, sets); + auto dc_ptr = graph.emplace_node(sets, 6); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -71,7 +71,7 @@ TEST_CASE("IsDisjointCoverNode") { } } THEN("We cannot construct a IsDisjointCover node with a smaller primary set size") { - CHECK_THROWS(graph.emplace_node(4, sets)); + CHECK_THROWS(graph.emplace_node(sets, 4)); } } @@ -81,7 +81,7 @@ TEST_CASE("IsDisjointCoverNode") { std::vector sets{c1_ptr, c2_ptr}; THEN("We can construct a IsDisjointCover node") { - auto dc_ptr = graph.emplace_node(5, sets); + auto dc_ptr = graph.emplace_node(sets, 5); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -105,7 +105,7 @@ TEST_CASE("IsDisjointCoverNode") { std::vector sets{set1_ptr, set2_ptr}; THEN("We can construct a IsDisjointCover node") { - auto dc_ptr = graph.emplace_node(5, sets); + auto dc_ptr = graph.emplace_node(sets, 5); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); @@ -210,7 +210,7 @@ TEST_CASE("IsDisjointCoverNode") { std::vector lists{list1_ptr, list2_ptr, list3_ptr}; THEN("We can construct a IsDisjointCover node") { - auto dc_ptr = graph.emplace_node(5, lists); + auto dc_ptr = graph.emplace_node(lists, 5); graph.emplace_node(dc_ptr); CHECK(dc_ptr->min() == 0.0); diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 18cbe7ee2..7ec57633d 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1504,7 +1504,7 @@ def generate_symbols(self): model.constant([0, 1, 2]), model.constant([3, 4]) ] - cover = dwave.optimization.symbols.IsDisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(sets, 5) with model.lock(): yield cover @@ -1516,7 +1516,7 @@ def test(self): model.constant([0, 1, 2]), model.constant([3, 4]) ] - cover = dwave.optimization.symbols.IsDisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(sets, 5) self.assertIsInstance(cover, IsDisjointCover) def test_state(self): @@ -1526,7 +1526,7 @@ def test_state(self): model.constant([0, 1, 2]), model.constant([3, 4]) ] - cover = dwave.optimization.symbols.IsDisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(sets, 5) model.states.resize(1) with model.lock(): expected = np.array([1.0]) @@ -1537,7 +1537,7 @@ def test_state(self): model.constant([0, 1, 2]), model.constant([2, 3, 4]) ] - cover = dwave.optimization.symbols.IsDisjointCover(5, sets) + cover = dwave.optimization.symbols.IsDisjointCover(sets, 5) with model.lock(): expected = np.array([0.0]) np.testing.assert_array_almost_equal(cover.state(0), expected)