From 953304e70fea92d41770c2d14d0a03870bfb3091 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 17 Jul 2026 16:20:56 -0700 Subject: [PATCH 01/37] Implement state checkpointing for CollectionNode --- .../include/dwave-optimization/graph.hpp | 5 + .../dwave-optimization/nodes/collections.hpp | 7 + .../include/dwave-optimization/state.hpp | 16 ++ dwave/optimization/src/graph.cpp | 5 + dwave/optimization/src/nodes/collections.cpp | 154 +++++++++++++++- ...eature-checkpointing-b770d2f2b66f648d.yaml | 7 + tests/cpp/nodes/test_collections.cpp | 169 ++++++++++++++++++ 7 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml diff --git a/dwave/optimization/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index 22fbc3d6..b05baa0e 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -151,6 +151,11 @@ class Graph { std::function accept = [](const Graph&, State&) { return true; } ) const; + /// Propagate any pending changes to all nodes in the graph and commit them. + void propose(State& state) const; + // dev note: the name is a bit funny in this case, but we essentially want + // an overload for a "default" `sources` and `accept`. + /// Initialize the state of the given node and all predecessors recursively. static void recursive_initialize(State& state, const Node* ptr); /// Reset the state of the given node and all successors recursively. diff --git a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp index 48b20119..68aeafae 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp @@ -32,8 +32,15 @@ class CollectionNode : public ArrayOutputMixin, public DecisionNode { // Set the node's state, tracking the diff. void assign(State& state, std::vector values) const; + /// Set the current state to match the one at the time the given checkpoint was created. + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const; + const double* buff(const State& state) const override; + /// Get a checkpoint, an IOU that can be used to return the node to its current state. + checkpoint_type checkpoint(State& state) const; + void commit(State&) const override; std::span diff(const State& state) const override; diff --git a/dwave/optimization/include/dwave-optimization/state.hpp b/dwave/optimization/include/dwave-optimization/state.hpp index 884bf972..e82b9cbc 100644 --- a/dwave/optimization/include/dwave-optimization/state.hpp +++ b/dwave/optimization/include/dwave-optimization/state.hpp @@ -36,4 +36,20 @@ struct NodeStateData { using State = typename std::vector>; +/// A generic base class for node checkpoints. +struct NodeStateCheckpoint { + NodeStateCheckpoint() = default; + NodeStateCheckpoint(const NodeStateCheckpoint&) = default; + NodeStateCheckpoint(NodeStateCheckpoint&&) = delete; + NodeStateCheckpoint& operator=(const NodeStateCheckpoint&) = default; + NodeStateCheckpoint& operator=(NodeStateCheckpoint&&) = delete; + + virtual ~NodeStateCheckpoint() = default; + + /// Whether the checkpoint is still available to be used. + virtual bool valid() const = 0; +}; + +using checkpoint_type = std::unique_ptr; + } // namespace dwave::optimization diff --git a/dwave/optimization/src/graph.cpp b/dwave/optimization/src/graph.cpp index 733e94ae..a625cd40 100644 --- a/dwave/optimization/src/graph.cpp +++ b/dwave/optimization/src/graph.cpp @@ -195,6 +195,11 @@ void Graph::propose( } } +void Graph::propose(State& state) const { + propagate(state); + commit(state); +} + void Graph::recursive_initialize(State& state, const Node* ptr) { ssize_t index = ptr->topological_index(); diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 0ea4f805..5dbb4d8b 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -14,9 +14,11 @@ #include "dwave-optimization/nodes/collections.hpp" +#include #include #include #include +#include namespace dwave::optimization { @@ -66,6 +68,51 @@ std::vector augment_collection_(std::vector values, const ssize_ return values; } +class CollectionStateData_; + +class CollectionCheckpoint_ : public NodeStateCheckpoint { + public: + CollectionCheckpoint_() = delete; + + CollectionCheckpoint_(CollectionStateData_* state_ptr); + + ~CollectionCheckpoint_() override; + + // detach the updates as a flattened view (in the forward order) + auto detach_updates() { + auto updates = std::move(updates_) | std::views::join; + assert(updates_.empty()); + return updates; + } + + ssize_t& drop() { return drop_; } + ssize_t drop() const { return drop_; } + + void emplace_updates(std::vector updates) { + if (drop_) { + // In C++23 we could use assign_range() which would be nicer + auto relevant = updates | std::views::drop(drop_); + updates_.emplace_back(relevant.begin(), relevant.end()); + drop_ = 0; + } else { + updates_.emplace_back(std::move(updates)); + } + } + + ssize_t size() { return size_; } + + bool valid() const override { return true; } + + private: + std::vector> updates_; + ssize_t drop_; + + ssize_t size_; + + CollectionCheckpoint_* older_checkpoint_ptr_; + std::variant newer_checkpoint_ptr_; +}; + class CollectionStateData_ : public NodeStateData { public: explicit CollectionStateData_(ssize_t n) : CollectionStateData_(n, n) {} @@ -109,11 +156,54 @@ class CollectionStateData_ : public NodeStateData { assert(this->size_ == size); } + void assign(std::unique_ptr& checkpoint) { + // convert the checkpoint into something we can read + auto* checkpoint_ptr = static_cast(checkpoint.get()); + + // Right now, you can only revert to the most recent checkpoint. It's + // pretty straightforward to support going further back, but this is all + // we need right now. + assert(older_checkpoint_ptr_ == checkpoint_ptr); + + // Ok, let's get ourselves to the same place as the checkpoint + + // we want to minimize the size of the visible buffer, so let's shrink ourselves + // if we need to + while (size_ > checkpoint_ptr->size()) shrink(); + + for (const auto& [idx, old, _] : checkpoint_ptr->detach_updates() | std::views::reverse) { + if (elements_[idx] == old) continue; // nothing to do + + all_updates_.emplace_back(idx, elements_[idx], old); + if (idx < size_) updates_.emplace_back(idx, elements_[idx], old); + + elements_[idx] = old; + } + + // now that we've filled in our buffer, grow until we're the correct size + while (size_ < checkpoint_ptr->size()) grow(); + + // update the "drop" value of the checkpoint so that our next commit doesn't + // add all of the changes we just added + checkpoint_ptr->drop() = all_updates_.size(); + } + const double* buff() const { return elements_.data(); } + std::unique_ptr checkpoint() { + return std::make_unique(this); + } + void commit() { updates_.clear(); - all_updates_.clear(); + + if (older_checkpoint_ptr_ != nullptr) { + older_checkpoint_ptr_->emplace_updates(std::move(all_updates_)); + assert(all_updates_.empty()); + } else { + all_updates_.clear(); + } + previous_size_ = size_; } @@ -213,8 +303,52 @@ class CollectionStateData_ : public NodeStateData { // commit/revert ssize_t size_; ssize_t previous_size_; + + friend CollectionCheckpoint_; + CollectionCheckpoint_* older_checkpoint_ptr_ = nullptr; }; +CollectionCheckpoint_::CollectionCheckpoint_(CollectionStateData_* state_ptr) : + updates_(), + drop_(state_ptr->all_updates_.size()), // so we ignore any updates added before we're made + size_(state_ptr->size()), + older_checkpoint_ptr_(state_ptr->older_checkpoint_ptr_), + newer_checkpoint_ptr_(state_ptr) { + if (older_checkpoint_ptr_ != nullptr) { + older_checkpoint_ptr_->newer_checkpoint_ptr_ = this; + + if (not state_ptr->all_updates_.empty()) { + older_checkpoint_ptr_->emplace_updates(state_ptr->all_updates_); // copy! + } + assert(older_checkpoint_ptr_->drop() == 0); + } + + std::get(newer_checkpoint_ptr_)->older_checkpoint_ptr_ = this; +} + +CollectionCheckpoint_::~CollectionCheckpoint_() { + if (older_checkpoint_ptr_ == nullptr) { + // We're the oldest checkpoint, so delete ourselves from the newer one + // and let any information we're holding die with us + std::visit([](auto* ptr) { ptr->older_checkpoint_ptr_ = nullptr; }, newer_checkpoint_ptr_); + } else { + // We're an intermediate checkpoint, so we need to pass any information we're + // holding to the next oldest checkpoint and update the pointers on either side of + // us + assert(older_checkpoint_ptr_->drop_ == 0); + for (std::vector& updates : updates_) { + older_checkpoint_ptr_->emplace_updates(std::move(updates)); + } + older_checkpoint_ptr_->drop_ = drop_; + + older_checkpoint_ptr_->newer_checkpoint_ptr_ = newer_checkpoint_ptr_; + std::visit( + [&](auto* ptr) { ptr->older_checkpoint_ptr_ = older_checkpoint_ptr_; }, + newer_checkpoint_ptr_ + ); + } +} + CollectionNode::CollectionNode(ssize_t max_value, ssize_t min_size, ssize_t max_size) : ArrayOutputMixin((min_size == max_size) ? max_size : Array::DYNAMIC_SIZE), max_value_(max_value), @@ -242,6 +376,24 @@ void CollectionNode::assign(State& state, std::vector values) const { data_ptr_(state)->assign(std::move(augemented), size); } +void CollectionNode::assign_from_checkpoint( + State& state, + std::unique_ptr& checkpoint +) const { + data_ptr_(state)->assign(checkpoint); +} +void CollectionNode::assign_from_checkpoint( + State& state, + std::unique_ptr&& checkpoint +) const { + assign_from_checkpoint(state, checkpoint); // call the lvalue version + checkpoint.reset(); +} + +std::unique_ptr CollectionNode::checkpoint(State& state) const { + return data_ptr_(state)->checkpoint(); +} + void CollectionNode::commit(State& state) const { data_ptr_(state)->commit(); } diff --git a/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml b/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml new file mode 100644 index 00000000..c82063e4 --- /dev/null +++ b/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml @@ -0,0 +1,7 @@ +--- +features: + - | + Add a C++ ``Graph::propose(State&)`` overload that propagates and commits. + - | + Add checkpointing to ``CollectionNode``. + See `#510 `_. diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index 4090de04..218d0cff 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -703,6 +703,175 @@ TEST_CASE("SetNode") { } } } + + GIVEN("A set(5) initialized to {0, 1}") { + auto graph = Graph(); + + auto* set_ptr = graph.emplace_node(5); + + graph.emplace_node(set_ptr); + + auto state = graph.empty_state(); + set_ptr->initialize_state(state, {0, 1}); + graph.initialize_state(state); + + WHEN("We create a checkpoint from the initialized state") { + auto checkpoint0 = set_ptr->checkpoint(state); + + AND_WHEN("The set is changed to {3, 4, 1}") { + set_ptr->assign(state, {3, 4, 1}); + + graph.propose(state); + CHECK(set_ptr->size(state) == 3); + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + AND_WHEN("We revert to the checkpoint") { + set_ptr->assign_from_checkpoint(state, checkpoint0); + graph.propose(state); + + THEN("The state has returned to {0, 1}") { + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + } + + AND_WHEN( + "The set is again mutated and then reverted using the same checkpoint" + ) { + set_ptr->assign(state, {4, 1, 0}); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1, 0})); + + set_ptr->assign_from_checkpoint(state, checkpoint0); + graph.propose(state); + + THEN("The state has returned to {0, 1}") { + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + } + } + } + + AND_WHEN("The set is changed to {3, 4, 1} and then the checkpoint is returned") { + set_ptr->assign(state, {3, 4, 1}); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint0)); + graph.propose(state); + + THEN("The state has returned to {0, 1} and the checkpoint is reset") { + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + CHECK(checkpoint0 == nullptr); + } + } + + AND_WHEN("We create another checkpoint") { + auto checkpoint1 = set_ptr->checkpoint(state); + + AND_WHEN("The set is changed to {4, 1}") { + set_ptr->assign(state, {4, 1}); + graph.propose(state); + + THEN("We can revert to the checkpoints one-by-one") { + set_ptr->assign_from_checkpoint(state, std::move(checkpoint1)); + graph.propose(state); + + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + set_ptr->assign_from_checkpoint(state, checkpoint0); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + } + } + } + } + } + + WHEN("We mutate the state and then create a checkpoint before commiting") { + set_ptr->assign(state, {4, 1}); + auto checkpoint = set_ptr->checkpoint(state); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + + set_ptr->assign(state, {3, 4, 1}); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + set_ptr->assign_from_checkpoint(state, checkpoint); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + } + + WHEN("We do several mutations and create several checkpoints within the same commit") { + auto checkpoint0 = set_ptr->checkpoint(state); + + set_ptr->assign(state, {4, 1}); + auto checkpoint1 = set_ptr->checkpoint(state); + + set_ptr->assign(state, {3, 4, 1}); + auto checkpoint2 = set_ptr->checkpoint(state); + + set_ptr->assign(state, {4, 2}); + graph.propose(state); // mix a propose in there + auto checkpoint3 = set_ptr->checkpoint(state); + + set_ptr->assign(state, {2}); + auto checkpoint4 = set_ptr->checkpoint(state); + + set_ptr->assign(state, {3, 2, 1, 0}); + graph.propose(state); + + THEN("we can go backwards through them without commiting and everything is correct") { + set_ptr->assign_from_checkpoint(state, std::move(checkpoint4)); + CHECK_THAT(set_ptr->view(state), RangeEquals({2})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint3)); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 2})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint2)); + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint1)); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint0)); + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + } + + THEN( + "we can go backwards through them and commit each time and everything is correct" + ) { + set_ptr->assign_from_checkpoint(state, std::move(checkpoint4)); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({2})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint3)); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 2})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint2)); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint1)); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint0)); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + } + + THEN("we can delete some intermediate checkpoints and everything stays valid") { + checkpoint2.reset(); + checkpoint4.reset(); + checkpoint0.reset(); + checkpoint3.reset(); + + set_ptr->assign_from_checkpoint(state, checkpoint1); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + } + } + } } } // namespace dwave::optimization From 214c5e23952649ba5adb24e8c74f3bcffe55e9e2 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 20 Jul 2026 11:49:23 -0700 Subject: [PATCH 02/37] Fix reverts and dangling pointers for CollectionNode checkpoints --- .../include/dwave-optimization/array.hpp | 3 + dwave/optimization/src/nodes/collections.cpp | 75 +++++++-- tests/cpp/nodes/test_collections.cpp | 143 ++++++++++++++++-- 3 files changed, 196 insertions(+), 25 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/array.hpp b/dwave/optimization/include/dwave-optimization/array.hpp index 7182b3af..8ece49d1 100644 --- a/dwave/optimization/include/dwave-optimization/array.hpp +++ b/dwave/optimization/include/dwave-optimization/array.hpp @@ -347,6 +347,9 @@ struct Update { // Return true if the update does nothing - that is old and value are the same. bool identity() const { return null() || old == value; } + // Return the update that would undo the current update + Update inverse() const { return Update(index, value, old); } + // Use NaN to represent the "nothing" value used in placements/removals static constexpr double nothing = std::numeric_limits::signaling_NaN(); diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 5dbb4d8b..0d168ab6 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -88,15 +88,36 @@ class CollectionCheckpoint_ : public NodeStateCheckpoint { ssize_t& drop() { return drop_; } ssize_t drop() const { return drop_; } - void emplace_updates(std::vector updates) { - if (drop_) { - // In C++23 we could use assign_range() which would be nicer - auto relevant = updates | std::views::drop(drop_); - updates_.emplace_back(relevant.begin(), relevant.end()); - drop_ = 0; - } else { + // Track the updates associated with a commit + void commit_updates(std::vector updates) { + assert(0 <= drop_ and static_cast(drop_) <= updates.size()); + + if (not drop_) { updates_.emplace_back(std::move(updates)); + return; } + + // Otherwise we only want to take the updates up to drop + // In C++23 we could use assign_range() which would be nicer + auto relevant = std::move(updates) | std::views::drop(drop_); + updates_.emplace_back(relevant.begin(), relevant.end()); + drop_ = 0; + } + + // Track the updates associated with a revert + void revert_updates(std::vector updates) { + assert(0 <= drop_ and static_cast(drop_) <= updates.size()); + + if (not drop_) return; // nothing to do + + // We want to track the updates that would revert the changes from the + // current state. + // In C++23 we could use assign_range() which would be nicer + auto relevant = std::move(updates) | std::views::take(drop_) | std::views::reverse | + std::views::transform([](const Update& up) { return up.inverse(); }); + updates_.emplace_back(relevant.begin(), relevant.end()); + + drop_ = 0; } ssize_t size() { return size_; } @@ -104,6 +125,8 @@ class CollectionCheckpoint_ : public NodeStateCheckpoint { bool valid() const override { return true; } private: + friend CollectionStateData_; + std::vector> updates_; ssize_t drop_; @@ -130,6 +153,15 @@ class CollectionStateData_ : public NodeStateData { assert(0 <= size_ and static_cast(size_) <= elements_.size()); } + ~CollectionStateData_() { + // make sure if we're destructed before the checkpoint that we clean + // up the dangling pointer + if (older_checkpoint_ptr_ != nullptr) { + older_checkpoint_ptr_->newer_checkpoint_ptr_ = + static_cast(nullptr); + } + } + void assign(std::vector&& values, ssize_t size) { // this should have been checked already by the CollectionNode assert(values.size() == elements_.size()); @@ -198,7 +230,7 @@ class CollectionStateData_ : public NodeStateData { updates_.clear(); if (older_checkpoint_ptr_ != nullptr) { - older_checkpoint_ptr_->emplace_updates(std::move(all_updates_)); + older_checkpoint_ptr_->commit_updates(std::move(all_updates_)); assert(all_updates_.empty()); } else { all_updates_.clear(); @@ -208,7 +240,9 @@ class CollectionStateData_ : public NodeStateData { } std::unique_ptr copy() const override { - return std::make_unique(*this); + auto uptr = std::make_unique(*this); + uptr->older_checkpoint_ptr_ = nullptr; // doesn't get to keep the checkpoints + return uptr; } std::span diff() const { return updates_; } @@ -240,16 +274,22 @@ class CollectionStateData_ : public NodeStateData { } void revert() { + updates_.clear(); + // Un-apply any changes by working backwards through all updates. // If we end up enforcing updates being sorted and unique later then // we could do this any order (or better in parallel). - for (const Update& update : all_updates_ | std::views::reverse) { elements_[update.index] = update.old; } - updates_.clear(); - all_updates_.clear(); + if (older_checkpoint_ptr_ != nullptr) { + older_checkpoint_ptr_->revert_updates(std::move(all_updates_)); + assert(all_updates_.empty()); + } else { + all_updates_.clear(); + } + size_ = previous_size_; } @@ -318,7 +358,7 @@ CollectionCheckpoint_::CollectionCheckpoint_(CollectionStateData_* state_ptr) : older_checkpoint_ptr_->newer_checkpoint_ptr_ = this; if (not state_ptr->all_updates_.empty()) { - older_checkpoint_ptr_->emplace_updates(state_ptr->all_updates_); // copy! + older_checkpoint_ptr_->commit_updates(state_ptr->all_updates_); // copy! } assert(older_checkpoint_ptr_->drop() == 0); } @@ -330,14 +370,19 @@ CollectionCheckpoint_::~CollectionCheckpoint_() { if (older_checkpoint_ptr_ == nullptr) { // We're the oldest checkpoint, so delete ourselves from the newer one // and let any information we're holding die with us - std::visit([](auto* ptr) { ptr->older_checkpoint_ptr_ = nullptr; }, newer_checkpoint_ptr_); + std::visit( + [](auto* ptr) { + if (ptr != nullptr) ptr->older_checkpoint_ptr_ = nullptr; + }, + newer_checkpoint_ptr_ + ); } else { // We're an intermediate checkpoint, so we need to pass any information we're // holding to the next oldest checkpoint and update the pointers on either side of // us assert(older_checkpoint_ptr_->drop_ == 0); for (std::vector& updates : updates_) { - older_checkpoint_ptr_->emplace_updates(std::move(updates)); + older_checkpoint_ptr_->commit_updates(std::move(updates)); } older_checkpoint_ptr_->drop_ = drop_; diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index 218d0cff..ed4818f6 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include #include -#include #include -#include +#include #include "dwave-optimization/graph.hpp" #include "dwave-optimization/nodes/collections.hpp" @@ -783,21 +783,68 @@ TEST_CASE("SetNode") { } } } + + AND_WHEN("We destruct the state before the checkpoint") { + state = graph.empty_state(); + checkpoint0.reset(); + } } WHEN("We mutate the state and then create a checkpoint before commiting") { set_ptr->assign(state, {4, 1}); auto checkpoint = set_ptr->checkpoint(state); - graph.propose(state); - CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); - set_ptr->assign(state, {3, 4, 1}); - graph.propose(state); - CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + AND_WHEN("We do a sequence of commits") { + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); - set_ptr->assign_from_checkpoint(state, checkpoint); - graph.propose(state); - CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + set_ptr->assign(state, {3, 4, 1}); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 4, 1})); + + set_ptr->assign_from_checkpoint(state, checkpoint); + graph.propose(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + } + + AND_WHEN("We revert and then restore from the checkpoint") { + graph.propagate(state); + graph.revert(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + graph.propagate(state); + AND_WHEN("we commit the change to the checkpoint") { + graph.commit(state); + + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + } + } + + AND_WHEN("We make more changes, save another checkpoint and then revert") { + set_ptr->assign(state, {3, 2, 1, 0}); + auto checkpoint1 = set_ptr->checkpoint(state); + + graph.propagate(state); + graph.revert(state); + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + + AND_WHEN("We revert to the first checkpoint") { + checkpoint1.reset(); // need to get rid of the second checkpoint first + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + graph.propose(state); + + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); + } + + AND_WHEN("We revert to the second checkpoint") { + set_ptr->assign_from_checkpoint(state, std::move(checkpoint1)); + graph.propose(state); + + CHECK_THAT(set_ptr->view(state), RangeEquals({3, 2, 1, 0})); + } + } } WHEN("We do several mutations and create several checkpoints within the same commit") { @@ -870,6 +917,82 @@ TEST_CASE("SetNode") { graph.propose(state); CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); } + + WHEN("We do some fuzzing with checkpoints") { + auto rng = std::default_random_engine(); + + // let's start by making a bunch of checkpoints with a copy of the buffer + // when the checkpoint was made + std::vector>> checkpoints; + { + // Randomly generate a state. There are more efficient ways + // to do this probably but for this test this is sufficient. + auto buffer = [&]() -> std::vector { + std::vector buff(5); + std::iota(buff.begin(), buff.end(), 0); + std::shuffle(buff.begin(), buff.end(), rng); + + std::uniform_int_distribution len(0, 4); + buff.erase(buff.begin() + len(rng), buff.end()); + + return buff; + }; + + // Commit anything that's pending + graph.propose(state); + + // Now, do a bunch of random actions + std::uniform_int_distribution action(0, 6); + for (int step = 0; step < 500; ++step) { + switch (action(rng)) { + case 0: + // make a checkpoint, tracking the current visible buffer + checkpoints.emplace_back( + set_ptr->checkpoint(state), + std::vector(set_ptr->begin(state), set_ptr->end(state)) + ); + break; + case 1: + // make a commit + graph.propagate(state); + graph.commit(state); + break; + case 2: + // make a revert + graph.propagate(state); + graph.revert(state); + break; + default: // we want to oversample this one + // assign a new state + set_ptr->assign(state, buffer()); + break; + } + } + + // Commit anything that's left over before the next step + graph.propose(state); + } + + // now, moving backwards through those checkpoints, let's randomly + // restore the state to the checkpoint or drop it + std::uniform_int_distribution flip(0, 1); + for (auto& [check, buff] : checkpoints | std::views::reverse) { + if (flip(rng)) { + set_ptr->assign_from_checkpoint(state, std::move(check)); + graph.propagate(state); + + CHECK_THAT(set_ptr->view(state), RangeEquals(buff)); + + if (flip(rng)) { + graph.commit(state); + } else { + graph.revert(state); + } + } else { + check.reset(); + } + } + } } } } From 9cd035b991321b3d67f390aabcca2df34247b414 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 20 Jul 2026 12:14:21 -0700 Subject: [PATCH 03/37] Use offical Python images in CircleCI This avoids using GCC11 which had some bugs in their ranges implementation. --- .circleci/config.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 08b2bc79..343d55d7 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,7 +30,7 @@ environment: &global-environment jobs: python-linux: docker: - - image: cimg/python:3.13 # just need a version that can install cibuildwheel + - image: cimg/python:3.13 # need a version that can install cibuildwheel and that has docker environment: <<: *global-environment @@ -57,7 +57,7 @@ jobs: python-linux-debug: docker: - - image: cimg/python:3.10 + - image: python:3.10 steps: - checkout @@ -127,7 +127,7 @@ jobs: python-sdist: docker: - - image: cimg/python:3.10 + - image: python:3.10 steps: - checkout @@ -239,7 +239,7 @@ jobs: serialization: docker: - - image: cimg/python:3.13 + - image: python:3.13 steps: - checkout @@ -282,7 +282,7 @@ jobs: docs: docker: - - image: cimg/python:3.13 # As of April 2026, the dwave-ocean-sdk uses 3.13 + - image: python:3.13 # As of April 2026, the dwave-ocean-sdk uses 3.13 steps: - checkout @@ -337,7 +337,7 @@ jobs: deploy: docker: - - image: cimg/python:3.10 + - image: python:3.10 steps: - attach_workspace: From 6ec49f916aff1150de913c046ac277f03c323b2c Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 20 Jul 2026 15:05:38 -0700 Subject: [PATCH 04/37] Add LinkedListCheckpoint helper class --- .../include/dwave-optimization/state.hpp | 4 +- dwave/optimization/src/nodes/_checkpoints.cpp | 44 +++++++ dwave/optimization/src/nodes/_checkpoints.hpp | 73 ++++++++++++ dwave/optimization/src/nodes/collections.cpp | 107 +++++------------- meson.build | 1 + 5 files changed, 151 insertions(+), 78 deletions(-) create mode 100644 dwave/optimization/src/nodes/_checkpoints.cpp create mode 100644 dwave/optimization/src/nodes/_checkpoints.hpp diff --git a/dwave/optimization/include/dwave-optimization/state.hpp b/dwave/optimization/include/dwave-optimization/state.hpp index e82b9cbc..a4a7e075 100644 --- a/dwave/optimization/include/dwave-optimization/state.hpp +++ b/dwave/optimization/include/dwave-optimization/state.hpp @@ -39,9 +39,9 @@ using State = typename std::vector>; /// A generic base class for node checkpoints. struct NodeStateCheckpoint { NodeStateCheckpoint() = default; - NodeStateCheckpoint(const NodeStateCheckpoint&) = default; + NodeStateCheckpoint(const NodeStateCheckpoint&) = delete; NodeStateCheckpoint(NodeStateCheckpoint&&) = delete; - NodeStateCheckpoint& operator=(const NodeStateCheckpoint&) = default; + NodeStateCheckpoint& operator=(const NodeStateCheckpoint&) = delete; NodeStateCheckpoint& operator=(NodeStateCheckpoint&&) = delete; virtual ~NodeStateCheckpoint() = default; diff --git a/dwave/optimization/src/nodes/_checkpoints.cpp b/dwave/optimization/src/nodes/_checkpoints.cpp new file mode 100644 index 00000000..77d2e95b --- /dev/null +++ b/dwave/optimization/src/nodes/_checkpoints.cpp @@ -0,0 +1,44 @@ +// Copyright 2026 D-Wave +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "_checkpoints.hpp" + +namespace dwave::optimization { + +// Place self between the state and any checkpoint it's currently holding +LinkedListCheckpoint::LinkedListCheckpoint(CheckpointableState& state) : + prev_ptr_(state.prev_ptr_), next_ptr_(&state) { + if (prev_ptr_ != nullptr) prev_ptr_->next_ptr_ = this; + state.prev_ptr_ = this; +} + +LinkedListCheckpoint::~LinkedListCheckpoint() { + if (prev_ptr_ != nullptr) prev_ptr_->next_ptr_ = next_ptr_; + + // Now make sure next_ptr is pointing to prev_ptr (which can be null) + std::visit( + [&](auto* next_ptr) -> void { + if (next_ptr == nullptr) return; // state was destructed first + next_ptr->prev_ptr_ = prev_ptr_; + }, + next_ptr_ + ); +} + +CheckpointableState::~CheckpointableState() { + if (prev_ptr_ == nullptr) return; // nothing to clean up + prev_ptr_->next_ptr_ = static_cast(nullptr); +} + +} // namespace dwave::optimization diff --git a/dwave/optimization/src/nodes/_checkpoints.hpp b/dwave/optimization/src/nodes/_checkpoints.hpp new file mode 100644 index 00000000..4f439daa --- /dev/null +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -0,0 +1,73 @@ +// Copyright 2026 D-Wave +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +#include "dwave-optimization/state.hpp" + +namespace dwave::optimization { + +class CheckpointableState; + +class LinkedListCheckpoint : public NodeStateCheckpoint { + public: + LinkedListCheckpoint() = delete; + // We're not moveable or copyable because NodeStateCheckpoint is not. + + LinkedListCheckpoint(CheckpointableState& state); + + ~LinkedListCheckpoint() override; + + protected: // todo: private? + friend CheckpointableState; + + LinkedListCheckpoint* prev_ptr_; + + // Is usually not nullptr unless the state has been destructed + std::variant next_ptr_; +}; + +class CheckpointableState : public NodeStateData { + public: + CheckpointableState() = default; + + CheckpointableState(const CheckpointableState& other) { + assert(false); + } + CheckpointableState(CheckpointableState&&) = default; + + CheckpointableState& operator=(const CheckpointableState&) { + assert(false); + } + CheckpointableState& operator=(CheckpointableState&&) = default; + + ~CheckpointableState(); + + protected: + template T> + T* checkpoint_ptr() { + return static_cast(prev_ptr_); + } + + private: // todo: private? + friend LinkedListCheckpoint; + + // The name is a bit confusing, but by making it match LinkedListCheckpoint::prev_ptr_ + // it makes the implementations of the various visit methods clearer. + LinkedListCheckpoint* prev_ptr_ = nullptr; // Will be nullptr if there are no checkpoints +}; + +} // namespace dwave::optimization diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 0d168ab6..1d92bd49 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -18,7 +18,8 @@ #include #include #include -#include + +#include "_checkpoints.hpp" namespace dwave::optimization { @@ -70,13 +71,23 @@ std::vector augment_collection_(std::vector values, const ssize_ class CollectionStateData_; -class CollectionCheckpoint_ : public NodeStateCheckpoint { +class CollectionCheckpoint_ : public LinkedListCheckpoint { public: CollectionCheckpoint_() = delete; - CollectionCheckpoint_(CollectionStateData_* state_ptr); + CollectionCheckpoint_(CollectionStateData_& state); + + ~CollectionCheckpoint_() override { + // if we're the oldest checkpoint, just let whatever information we're + // holding get destructed with us + if (prev_ptr_ == nullptr) return; - ~CollectionCheckpoint_() override; + // otherwise we need to transfer our info over + auto* prev_ptr = static_cast(prev_ptr_); + assert(prev_ptr->drop_ == 0); + for (auto& updates : updates_) prev_ptr->commit_updates(std::move(updates)); + prev_ptr->drop_ = drop_; + } // detach the updates as a flattened view (in the forward order) auto detach_updates() { @@ -125,18 +136,13 @@ class CollectionCheckpoint_ : public NodeStateCheckpoint { bool valid() const override { return true; } private: - friend CollectionStateData_; - std::vector> updates_; ssize_t drop_; ssize_t size_; - - CollectionCheckpoint_* older_checkpoint_ptr_; - std::variant newer_checkpoint_ptr_; }; -class CollectionStateData_ : public NodeStateData { +class CollectionStateData_ : public CheckpointableState { public: explicit CollectionStateData_(ssize_t n) : CollectionStateData_(n, n) {} @@ -153,15 +159,6 @@ class CollectionStateData_ : public NodeStateData { assert(0 <= size_ and static_cast(size_) <= elements_.size()); } - ~CollectionStateData_() { - // make sure if we're destructed before the checkpoint that we clean - // up the dangling pointer - if (older_checkpoint_ptr_ != nullptr) { - older_checkpoint_ptr_->newer_checkpoint_ptr_ = - static_cast(nullptr); - } - } - void assign(std::vector&& values, ssize_t size) { // this should have been checked already by the CollectionNode assert(values.size() == elements_.size()); @@ -195,7 +192,7 @@ class CollectionStateData_ : public NodeStateData { // Right now, you can only revert to the most recent checkpoint. It's // pretty straightforward to support going further back, but this is all // we need right now. - assert(older_checkpoint_ptr_ == checkpoint_ptr); + assert(this->checkpoint_ptr() == checkpoint_ptr); // Ok, let's get ourselves to the same place as the checkpoint @@ -223,14 +220,14 @@ class CollectionStateData_ : public NodeStateData { const double* buff() const { return elements_.data(); } std::unique_ptr checkpoint() { - return std::make_unique(this); + return std::make_unique(*this); } void commit() { updates_.clear(); - if (older_checkpoint_ptr_ != nullptr) { - older_checkpoint_ptr_->commit_updates(std::move(all_updates_)); + if (auto* checkpoint_ptr = this->checkpoint_ptr()) { + checkpoint_ptr->commit_updates(std::move(all_updates_)); assert(all_updates_.empty()); } else { all_updates_.clear(); @@ -239,12 +236,6 @@ class CollectionStateData_ : public NodeStateData { previous_size_ = size_; } - std::unique_ptr copy() const override { - auto uptr = std::make_unique(*this); - uptr->older_checkpoint_ptr_ = nullptr; // doesn't get to keep the checkpoints - return uptr; - } - std::span diff() const { return updates_; } void exchange(ssize_t i, ssize_t j) { @@ -283,8 +274,8 @@ class CollectionStateData_ : public NodeStateData { elements_[update.index] = update.old; } - if (older_checkpoint_ptr_ != nullptr) { - older_checkpoint_ptr_->revert_updates(std::move(all_updates_)); + if (auto* checkpoint_ptr = this->checkpoint_ptr()) { + checkpoint_ptr->revert_updates(std::move(all_updates_)); assert(all_updates_.empty()); } else { all_updates_.clear(); @@ -329,6 +320,8 @@ class CollectionStateData_ : public NodeStateData { ssize_t size_diff() const { return size_ - previous_size_; } private: + friend CollectionCheckpoint_; + // The elements in the collection std::vector elements_; @@ -343,54 +336,16 @@ class CollectionStateData_ : public NodeStateData { // commit/revert ssize_t size_; ssize_t previous_size_; - - friend CollectionCheckpoint_; - CollectionCheckpoint_* older_checkpoint_ptr_ = nullptr; }; -CollectionCheckpoint_::CollectionCheckpoint_(CollectionStateData_* state_ptr) : +CollectionCheckpoint_::CollectionCheckpoint_(CollectionStateData_& state) : + LinkedListCheckpoint(state), updates_(), - drop_(state_ptr->all_updates_.size()), // so we ignore any updates added before we're made - size_(state_ptr->size()), - older_checkpoint_ptr_(state_ptr->older_checkpoint_ptr_), - newer_checkpoint_ptr_(state_ptr) { - if (older_checkpoint_ptr_ != nullptr) { - older_checkpoint_ptr_->newer_checkpoint_ptr_ = this; - - if (not state_ptr->all_updates_.empty()) { - older_checkpoint_ptr_->commit_updates(state_ptr->all_updates_); // copy! - } - assert(older_checkpoint_ptr_->drop() == 0); - } - - std::get(newer_checkpoint_ptr_)->older_checkpoint_ptr_ = this; -} - -CollectionCheckpoint_::~CollectionCheckpoint_() { - if (older_checkpoint_ptr_ == nullptr) { - // We're the oldest checkpoint, so delete ourselves from the newer one - // and let any information we're holding die with us - std::visit( - [](auto* ptr) { - if (ptr != nullptr) ptr->older_checkpoint_ptr_ = nullptr; - }, - newer_checkpoint_ptr_ - ); - } else { - // We're an intermediate checkpoint, so we need to pass any information we're - // holding to the next oldest checkpoint and update the pointers on either side of - // us - assert(older_checkpoint_ptr_->drop_ == 0); - for (std::vector& updates : updates_) { - older_checkpoint_ptr_->commit_updates(std::move(updates)); - } - older_checkpoint_ptr_->drop_ = drop_; - - older_checkpoint_ptr_->newer_checkpoint_ptr_ = newer_checkpoint_ptr_; - std::visit( - [&](auto* ptr) { ptr->older_checkpoint_ptr_ = older_checkpoint_ptr_; }, - newer_checkpoint_ptr_ - ); + drop_(state.all_updates_.size()), // so we ignore any updates added before we're made + size_(state.size()) { + if (auto* prev_checkpoint = static_cast(prev_ptr_)) { + prev_checkpoint->commit_updates(state.all_updates_); + assert(prev_checkpoint->drop() == 0); } } diff --git a/meson.build b/meson.build index 690d7165..b8882ec8 100644 --- a/meson.build +++ b/meson.build @@ -27,6 +27,7 @@ py = import('python').find_installation(pure: false) dwave_optimization_include = include_directories('dwave/optimization/include/') dwave_optimization_src = [ + 'dwave/optimization/src/nodes/_checkpoints.cpp', 'dwave/optimization/src/nodes/binaryop.cpp', 'dwave/optimization/src/nodes/collections.cpp', 'dwave/optimization/src/nodes/constants.cpp', From fc44824cba614127daaf23e057324698e50d975e Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 20 Jul 2026 15:15:16 -0700 Subject: [PATCH 05/37] Fix the copying of checkpointed states --- dwave/optimization/src/nodes/_checkpoints.hpp | 8 ++------ dwave/optimization/src/nodes/collections.cpp | 4 ++++ tests/cpp/nodes/test_collections.cpp | 6 ++++++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dwave/optimization/src/nodes/_checkpoints.hpp b/dwave/optimization/src/nodes/_checkpoints.hpp index 4f439daa..3753b78b 100644 --- a/dwave/optimization/src/nodes/_checkpoints.hpp +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -44,14 +44,10 @@ class CheckpointableState : public NodeStateData { public: CheckpointableState() = default; - CheckpointableState(const CheckpointableState& other) { - assert(false); - } + CheckpointableState(const CheckpointableState&) {} // the checkpoint pointer is not copied CheckpointableState(CheckpointableState&&) = default; - CheckpointableState& operator=(const CheckpointableState&) { - assert(false); - } + CheckpointableState& operator=(const CheckpointableState&) = delete; CheckpointableState& operator=(CheckpointableState&&) = default; ~CheckpointableState(); diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 1d92bd49..3f7e6c72 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -236,6 +236,10 @@ class CollectionStateData_ : public CheckpointableState { previous_size_ = size_; } + std::unique_ptr copy() const override { + return std::make_unique(*this); + } + std::span diff() const { return updates_; } void exchange(ssize_t i, ssize_t j) { diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index ed4818f6..c8ac10d0 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -788,6 +788,12 @@ TEST_CASE("SetNode") { state = graph.empty_state(); checkpoint0.reset(); } + + THEN("We can copy the state") { + auto cp = state[0]->copy(); + // this is a smoke test because there is no public way to check + // that the checkpoint didn't get copied over + } } WHEN("We mutate the state and then create a checkpoint before commiting") { From f711e63900c1cc1a5ae897011928d6cef448a85e Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 20 Jul 2026 15:25:51 -0700 Subject: [PATCH 06/37] Drop unused NodeStateCheckpoint::valid() method --- dwave/optimization/include/dwave-optimization/state.hpp | 3 --- dwave/optimization/src/nodes/collections.cpp | 2 -- 2 files changed, 5 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/state.hpp b/dwave/optimization/include/dwave-optimization/state.hpp index a4a7e075..4b270db9 100644 --- a/dwave/optimization/include/dwave-optimization/state.hpp +++ b/dwave/optimization/include/dwave-optimization/state.hpp @@ -45,9 +45,6 @@ struct NodeStateCheckpoint { NodeStateCheckpoint& operator=(NodeStateCheckpoint&&) = delete; virtual ~NodeStateCheckpoint() = default; - - /// Whether the checkpoint is still available to be used. - virtual bool valid() const = 0; }; using checkpoint_type = std::unique_ptr; diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 3f7e6c72..79f2afd6 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -133,8 +133,6 @@ class CollectionCheckpoint_ : public LinkedListCheckpoint { ssize_t size() { return size_; } - bool valid() const override { return true; } - private: std::vector> updates_; ssize_t drop_; From e85f85f2339c3c3a82810b44c2734c9e62fbf98c Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 21 Jul 2026 13:01:50 -0700 Subject: [PATCH 07/37] Add DiffCheckpoint class for checkpoints that track diffs --- dwave/optimization/src/nodes/_checkpoints.cpp | 52 ++++++++++++- dwave/optimization/src/nodes/_checkpoints.hpp | 62 ++++++++++----- dwave/optimization/src/nodes/_state.hpp | 16 ++++ dwave/optimization/src/nodes/collections.cpp | 75 +------------------ 4 files changed, 113 insertions(+), 92 deletions(-) diff --git a/dwave/optimization/src/nodes/_checkpoints.cpp b/dwave/optimization/src/nodes/_checkpoints.cpp index 77d2e95b..60a4d8bc 100644 --- a/dwave/optimization/src/nodes/_checkpoints.cpp +++ b/dwave/optimization/src/nodes/_checkpoints.cpp @@ -16,6 +16,11 @@ namespace dwave::optimization { +CheckpointableState::~CheckpointableState() { + if (prev_ptr_ == nullptr) return; // nothing to clean up + prev_ptr_->next_ptr_ = static_cast(nullptr); +} + // Place self between the state and any checkpoint it's currently holding LinkedListCheckpoint::LinkedListCheckpoint(CheckpointableState& state) : prev_ptr_(state.prev_ptr_), next_ptr_(&state) { @@ -36,9 +41,50 @@ LinkedListCheckpoint::~LinkedListCheckpoint() { ); } -CheckpointableState::~CheckpointableState() { - if (prev_ptr_ == nullptr) return; // nothing to clean up - prev_ptr_->next_ptr_ = static_cast(nullptr); +DiffCheckpoint::DiffCheckpoint(CheckpointableState& state, std::span diff) : + LinkedListCheckpoint(state), updates_(), drop_(diff.size()) { + if (auto* prev_ptr = static_cast(prev_ptr_)) { + prev_ptr->commit_updates(std::vector(diff.begin(), diff.end())); + assert(prev_ptr->drop_ == 0); + } +} + +DiffCheckpoint::~DiffCheckpoint() { + // if we're the oldest checkpoint, just let whatever information we're + // holding get destructed with us + if (prev_ptr_ == nullptr) return; + + // otherwise we need to transfer our info over + auto* prev_ptr = static_cast(prev_ptr_); + assert(prev_ptr->drop_ == 0); + for (auto& updates : updates_) prev_ptr->commit_updates(std::move(updates)); + prev_ptr->drop_ = drop_; +} + +void DiffCheckpoint::commit_updates(std::vector updates) { + assert(0 <= drop_ and static_cast(drop_) <= updates.size()); + + if (drop_) { + updates.erase(updates.begin(), updates.begin() + drop_); + drop_ = 0; + } + + updates_.emplace_back(std::move(updates)); +} + +void DiffCheckpoint::revert_updates(std::vector updates) { + assert(0 <= drop_ and static_cast(drop_) <= updates.size()); + + if (not drop_) return; // nothing to do + + // We want to track the updates that would revert the changes from the + // current state. + // In C++23 we could use assign_range() which would be nicer + auto relevant = std::move(updates) | std::views::take(drop_) | std::views::reverse | + std::views::transform([](const Update& up) { return up.inverse(); }); + updates_.emplace_back(relevant.begin(), relevant.end()); + + drop_ = 0; } } // namespace dwave::optimization diff --git a/dwave/optimization/src/nodes/_checkpoints.hpp b/dwave/optimization/src/nodes/_checkpoints.hpp index 3753b78b..39ac72db 100644 --- a/dwave/optimization/src/nodes/_checkpoints.hpp +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -14,13 +14,42 @@ #pragma once +#include #include +#include +#include "dwave-optimization/array.hpp" #include "dwave-optimization/state.hpp" namespace dwave::optimization { -class CheckpointableState; +class LinkedListCheckpoint; + +class CheckpointableState { + public: + CheckpointableState() = default; + + CheckpointableState(const CheckpointableState&) {} // the checkpoint pointer is not copied + CheckpointableState(CheckpointableState&&) = default; + + CheckpointableState& operator=(const CheckpointableState&) = delete; + CheckpointableState& operator=(CheckpointableState&&) = default; + + ~CheckpointableState(); + + protected: + template T> + T* checkpoint_ptr() { + return static_cast(prev_ptr_); + } + + private: // todo: private? + friend LinkedListCheckpoint; + + // The name is a bit confusing, but by making it match LinkedListCheckpoint::prev_ptr_ + // it makes the implementations of the various visit methods clearer. + LinkedListCheckpoint* prev_ptr_ = nullptr; // Will be nullptr if there are no checkpoints +}; class LinkedListCheckpoint : public NodeStateCheckpoint { public: @@ -40,30 +69,27 @@ class LinkedListCheckpoint : public NodeStateCheckpoint { std::variant next_ptr_; }; -class CheckpointableState : public NodeStateData { +class DiffCheckpoint : public LinkedListCheckpoint { public: - CheckpointableState() = default; + DiffCheckpoint(CheckpointableState& state, std::span diff); - CheckpointableState(const CheckpointableState&) {} // the checkpoint pointer is not copied - CheckpointableState(CheckpointableState&&) = default; + ~DiffCheckpoint() override; - CheckpointableState& operator=(const CheckpointableState&) = delete; - CheckpointableState& operator=(CheckpointableState&&) = default; - - ~CheckpointableState(); + void commit_updates(std::vector updates); - protected: - template T> - T* checkpoint_ptr() { - return static_cast(prev_ptr_); + auto detach_updates() { + auto updates = std::move(updates_) | std::views::join; + assert(updates_.empty()); + return updates; } - private: // todo: private? - friend LinkedListCheckpoint; + ssize_t& drop() { return drop_; } - // The name is a bit confusing, but by making it match LinkedListCheckpoint::prev_ptr_ - // it makes the implementations of the various visit methods clearer. - LinkedListCheckpoint* prev_ptr_ = nullptr; // Will be nullptr if there are no checkpoints + void revert_updates(std::vector updates); + + private: + std::vector> updates_; + ssize_t drop_; }; } // namespace dwave::optimization diff --git a/dwave/optimization/src/nodes/_state.hpp b/dwave/optimization/src/nodes/_state.hpp index bc715202..5f4c8162 100644 --- a/dwave/optimization/src/nodes/_state.hpp +++ b/dwave/optimization/src/nodes/_state.hpp @@ -91,6 +91,22 @@ class ArrayStateData { assert(size_ >= 0 && static_cast(size_) == buffer.size()); } + // Commit the changes and clear the diff by returning the diff buffer. + std::vector commit_and_detach() { + std::vector tmp; + std::swap(updates, tmp); + // AlexC: we could now do updates.reserve(tmp.size()) under the assumption + // that future update buffers will be a similar size. On the other hand, + // not doing this provides another meaningful difference to ::commit(). + // For now, I think it make sense to not but performance testing needed. + + previous_size_ = buffer.size(); + assert(size_ >= 0 && static_cast(size_) == buffer.size()); + + assert(updates.empty()); + return tmp; + } + std::span diff() const noexcept { return updates; } // Append a new value to the buffer, tracking the addition in the diff diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 79f2afd6..fb265ed6 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -71,76 +71,17 @@ std::vector augment_collection_(std::vector values, const ssize_ class CollectionStateData_; -class CollectionCheckpoint_ : public LinkedListCheckpoint { +class CollectionCheckpoint_ : public DiffCheckpoint { public: - CollectionCheckpoint_() = delete; - CollectionCheckpoint_(CollectionStateData_& state); - ~CollectionCheckpoint_() override { - // if we're the oldest checkpoint, just let whatever information we're - // holding get destructed with us - if (prev_ptr_ == nullptr) return; - - // otherwise we need to transfer our info over - auto* prev_ptr = static_cast(prev_ptr_); - assert(prev_ptr->drop_ == 0); - for (auto& updates : updates_) prev_ptr->commit_updates(std::move(updates)); - prev_ptr->drop_ = drop_; - } - - // detach the updates as a flattened view (in the forward order) - auto detach_updates() { - auto updates = std::move(updates_) | std::views::join; - assert(updates_.empty()); - return updates; - } - - ssize_t& drop() { return drop_; } - ssize_t drop() const { return drop_; } - - // Track the updates associated with a commit - void commit_updates(std::vector updates) { - assert(0 <= drop_ and static_cast(drop_) <= updates.size()); - - if (not drop_) { - updates_.emplace_back(std::move(updates)); - return; - } - - // Otherwise we only want to take the updates up to drop - // In C++23 we could use assign_range() which would be nicer - auto relevant = std::move(updates) | std::views::drop(drop_); - updates_.emplace_back(relevant.begin(), relevant.end()); - drop_ = 0; - } - - // Track the updates associated with a revert - void revert_updates(std::vector updates) { - assert(0 <= drop_ and static_cast(drop_) <= updates.size()); - - if (not drop_) return; // nothing to do - - // We want to track the updates that would revert the changes from the - // current state. - // In C++23 we could use assign_range() which would be nicer - auto relevant = std::move(updates) | std::views::take(drop_) | std::views::reverse | - std::views::transform([](const Update& up) { return up.inverse(); }); - updates_.emplace_back(relevant.begin(), relevant.end()); - - drop_ = 0; - } - - ssize_t size() { return size_; } + ssize_t size() const { return size_; } private: - std::vector> updates_; - ssize_t drop_; - ssize_t size_; }; -class CollectionStateData_ : public CheckpointableState { +class CollectionStateData_ : public NodeStateData, public CheckpointableState { public: explicit CollectionStateData_(ssize_t n) : CollectionStateData_(n, n) {} @@ -341,15 +282,7 @@ class CollectionStateData_ : public CheckpointableState { }; CollectionCheckpoint_::CollectionCheckpoint_(CollectionStateData_& state) : - LinkedListCheckpoint(state), - updates_(), - drop_(state.all_updates_.size()), // so we ignore any updates added before we're made - size_(state.size()) { - if (auto* prev_checkpoint = static_cast(prev_ptr_)) { - prev_checkpoint->commit_updates(state.all_updates_); - assert(prev_checkpoint->drop() == 0); - } -} + DiffCheckpoint(state, state.all_updates_), size_(state.size()) {} CollectionNode::CollectionNode(ssize_t max_value, ssize_t min_size, ssize_t max_size) : ArrayOutputMixin((min_size == max_size) ? max_size : Array::DYNAMIC_SIZE), From f70699e837c57a82d1b435c1e2489abbda66f4a1 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 21 Jul 2026 14:16:23 -0700 Subject: [PATCH 08/37] Go back to using CircleCI image for some CI tasks --- .circleci/config.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 343d55d7..b66c34b2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -239,7 +239,7 @@ jobs: serialization: docker: - - image: python:3.13 + - image: cimg/python:3.13 steps: - checkout @@ -282,7 +282,7 @@ jobs: docs: docker: - - image: python:3.13 # As of April 2026, the dwave-ocean-sdk uses 3.13 + - image: cimg/python:3.13 # As of April 2026, the dwave-ocean-sdk uses 3.13 steps: - checkout @@ -337,7 +337,7 @@ jobs: deploy: docker: - - image: python:3.10 + - image: cimg/python:3.10 steps: - attach_workspace: From ef950e42b4c04022c4a1061e815b6e2aba30a1fd Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Thu, 23 Jul 2026 15:28:45 -0700 Subject: [PATCH 09/37] Add NumberNode::checkpoint() --- .../dwave-optimization/nodes/numbers.hpp | 7 +- dwave/optimization/src/nodes/_checkpoints.cpp | 3 + dwave/optimization/src/nodes/_checkpoints.hpp | 3 + dwave/optimization/src/nodes/_state.hpp | 23 +++ dwave/optimization/src/nodes/numbers.cpp | 136 +++++++++++++++- tests/cpp/nodes/test_collections.cpp | 2 + tests/cpp/nodes/test_numbers.cpp | 149 +++++++++++++++++- 7 files changed, 313 insertions(+), 10 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 252e709f..34cc9069 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include "dwave-optimization/array.hpp" @@ -122,6 +121,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // NumberNode methods ***************************************************** + std::unique_ptr checkpoint(State& state) const; + // In the given state, swap the value of index i with the value of index j. // Users may pass the slices (per sum constraint) that each index lies on. void exchange( @@ -290,6 +291,10 @@ class IntegerNode : public NumberNode { // IntegerNode methods **************************************************** + /// Set the current state to match the one at the time the given checkpoint was created. + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const; + // Set the value at the given index in the given state. // Users may pass the slices (per sum constraint) that each index lies on. void set_value( diff --git a/dwave/optimization/src/nodes/_checkpoints.cpp b/dwave/optimization/src/nodes/_checkpoints.cpp index 60a4d8bc..4b3fe59b 100644 --- a/dwave/optimization/src/nodes/_checkpoints.cpp +++ b/dwave/optimization/src/nodes/_checkpoints.cpp @@ -41,6 +41,9 @@ LinkedListCheckpoint::~LinkedListCheckpoint() { ); } +DiffCheckpoint::DiffCheckpoint(CheckpointableState& state, ssize_t drop) : + LinkedListCheckpoint(state), updates_(), drop_(drop) {} + DiffCheckpoint::DiffCheckpoint(CheckpointableState& state, std::span diff) : LinkedListCheckpoint(state), updates_(), drop_(diff.size()) { if (auto* prev_ptr = static_cast(prev_ptr_)) { diff --git a/dwave/optimization/src/nodes/_checkpoints.hpp b/dwave/optimization/src/nodes/_checkpoints.hpp index 39ac72db..8e1866d7 100644 --- a/dwave/optimization/src/nodes/_checkpoints.hpp +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -87,6 +87,9 @@ class DiffCheckpoint : public LinkedListCheckpoint { void revert_updates(std::vector updates); + protected: + DiffCheckpoint(CheckpointableState& state, ssize_t drop); + private: std::vector> updates_; ssize_t drop_; diff --git a/dwave/optimization/src/nodes/_state.hpp b/dwave/optimization/src/nodes/_state.hpp index 5f4c8162..b5e4d812 100644 --- a/dwave/optimization/src/nodes/_state.hpp +++ b/dwave/optimization/src/nodes/_state.hpp @@ -165,6 +165,29 @@ class ArrayStateData { size_ = buffer.size(); } + // Commit the changes and clear the diff by returning the diff buffer. + std::vector revert_and_detach() { + assert(previous_size_ >= 0); + buffer.resize(previous_size_); + const ssize_t size = buffer.size(); + for (const auto& [index, old, _] : updates | std::views::reverse) { + assert(index >= 0); + if (index >= size) continue; + buffer[index] = old; + } + size_ = buffer.size(); + + std::vector tmp; + std::swap(updates, tmp); + // AlexC: we could now do updates.reserve(tmp.size()) under the assumption + // that future update buffers will be a similar size. On the other hand, + // not doing this provides another meaningful difference to ::commit(). + // For now, I think it make sense to not but performance testing needed. + + assert(updates.empty()); + return tmp; + } + // Set the value at index, tracking the change in the diff. // If allow_emplace is true, do an emplace_back iff the index is equal to the current size. bool set(ssize_t i, double value, bool allow_emplace = false) { diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 317082ed..30920d53 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -23,6 +23,7 @@ #include #include +#include "_checkpoints.hpp" #include "_state.hpp" #include "dwave-optimization/array.hpp" #include "dwave-optimization/common.hpp" @@ -72,8 +73,75 @@ NumberNode::SumConstraint::Operator NumberNode::SumConstraint::op(const ssize_t return operators_[slice]; } +class NumberNodeCheckpoint_ : public DiffCheckpoint { + public: + using slice_cache_type = std::vector>; + + NumberNodeCheckpoint_( + CheckpointableState& state, + std::span diff, + const slice_cache_type& slice_cache + ) : + DiffCheckpoint(state, diff.size()), slice_caches_() { + // If there is an older checkpoint, we want to put anything we're currently + // holding in our slice cache onto it + if (auto* prev_ptr = static_cast(prev_ptr_)) { + prev_ptr->commit_updates(std::vector(diff.begin(), diff.end()), slice_cache); + assert(prev_ptr->drop() == 0); + } + } + + void commit_updates(std::vector updates, slice_cache_type slice_cache) { + ssize_t drop = this->drop(); + assert(0 <= drop and static_cast(drop) <= updates.size()); + + if (not slice_cache.empty()) { + assert(updates.size() == slice_cache.size()); + + if (drop) { + slice_cache.erase(slice_cache.begin(), slice_cache.begin() + drop); + } + slice_caches_.emplace_back(std::move(slice_cache)); + } + + DiffCheckpoint::commit_updates(std::move(updates)); + assert(this->drop() == 0); + } + + auto detach_slice_cache() { + using join_type = decltype(std::move(slice_caches_) | std::views::join); + + if (slice_caches_.empty()) return std::optional(); + + auto joined = std::move(slice_caches_) | std::views::join; + assert(slice_caches_.empty()); + return std::optional(std::move(joined)); + } + + void revert_updates(std::vector updates, slice_cache_type slice_cache) { + ssize_t drop = this->drop(); + assert(0 <= drop and static_cast(drop) <= updates.size()); + + if (not slice_cache.empty()) { + assert(updates.size() == slice_cache.size()); + + if (drop) { + slice_cache.erase(slice_cache.begin() + drop, slice_cache.end()); + } + std::reverse(slice_cache.begin(), slice_cache.end()); + slice_caches_.emplace_back(std::move(slice_cache)); + } + + DiffCheckpoint::revert_updates(std::move(updates)); + assert(this->drop() == 0); + } + + private: + std::vector slice_caches_; +}; + /// State dependent data attached to NumberNode -struct NumberNodeStateData : public ArrayNodeStateData { +class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableState { public: // User does not provide sum constraints. NumberNodeStateData(std::vector input) : ArrayNodeStateData(std::move(input)) {} @@ -84,14 +152,28 @@ struct NumberNodeStateData : public ArrayNodeStateData { ) : ArrayNodeStateData(std::move(input)), sum_constraints_lhs(std::move(sum_constraints_lhs)) {} + std::unique_ptr checkpoint() { + return std::make_unique(*this, this->diff(), this->slice_cache_); + } + std::unique_ptr copy() const override { return std::make_unique(*this); } /// Commit the state dependent data of NumberNode. void commit() { - ArrayNodeStateData::commit(); // Commit changes to the buffer. - slice_cache_.clear(); // Empty the slice cache. + if (auto* checkpoint_ptr = this->checkpoint_ptr()) { + checkpoint_ptr->commit_updates( + ArrayNodeStateData::commit_and_detach(), std::move(slice_cache_) + ); + } else { + ArrayNodeStateData::commit(); // Commit changes to the buffer. + slice_cache_.clear(); // Empty the slice cache. + } + + // everything should have been cleared out regardless of which path we took + assert(this->diff().empty()); + assert(slice_cache_.empty()); } /// Revert the state dependent data of NumberNode. @@ -142,10 +224,16 @@ void NumberNodeStateData::revert() { sum_constraints_lhs[j][slices[j]] -= difference; } } - slice_cache_.clear(); // Empty the slice cache. } - ArrayNodeStateData::revert(); // Revert changes to the buffer. + if (auto* checkpoint_ptr = this->checkpoint_ptr()) { + checkpoint_ptr->revert_updates( + ArrayNodeStateData::revert_and_detach(), std::move(slice_cache_) + ); + } else { + slice_cache_.clear(); // Empty the slice cache. + ArrayNodeStateData::revert(); // Revert changes to the buffer. + } } void NumberNodeStateData::update( @@ -529,6 +617,10 @@ void NumberNode::propagate(State& state) const { } } +std::unique_ptr NumberNode::checkpoint(State& state) const { + return data_ptr_(state)->checkpoint(); +} + void NumberNode::commit(State& state) const noexcept { data_ptr_(state)->commit(); } @@ -953,6 +1045,40 @@ IntegerNode::IntegerNode( std::move(sum_constraints) ) {} +void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const { + auto state_data = data_ptr_(state); + + auto* checkpoint_ptr = static_cast(checkpoint.get()); + + // todo: assert that this checkpoint is the latest + + auto updates = checkpoint_ptr->detach_updates(); + auto slice_cache = checkpoint_ptr->detach_slice_cache(); // this is an std::optional<...>! + + if (slice_cache.has_value()) { + assert(sum_constraints_.size() > 0); + + auto slices_rit = std::ranges::rbegin(*slice_cache); + + for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { + state_data->set(idx, old); + state_data->update(*this, idx, old - diff(state).back().old, *(slices_rit++)); + } + } else { + assert(updates.empty() or sum_constraints_.empty()); + + // in this case we don't need to do anything to update the slice data + for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { + state_data->set(idx, old); + } + } +} + +void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const { + assign_from_checkpoint(state, checkpoint); // call the lvalue version + checkpoint.reset(); +} + bool IntegerNode::integral() const { return true; } bool IntegerNode::is_valid(ssize_t index, double value) const { diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index c8ac10d0..b7820e5f 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -873,6 +873,8 @@ TEST_CASE("SetNode") { graph.propose(state); THEN("we can go backwards through them without commiting and everything is correct") { + // TODO: check mutating before assigning + set_ptr->assign_from_checkpoint(state, std::move(checkpoint4)); CHECK_THAT(set_ptr->view(state), RangeEquals({2})); diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 4349961d..0e10ae0d 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -23,6 +23,7 @@ #include "dwave-optimization/graph.hpp" #include "dwave-optimization/nodes/numbers.hpp" +#include "dwave-optimization/nodes/testing.hpp" using Catch::Matchers::RangeEquals; @@ -1892,6 +1893,8 @@ TEST_CASE("IntegerNode") { GIVEN("An Integer Node representing an 1d array of 10 elements with lower bound -10") { auto ptr = graph.emplace_node(std::initializer_list{10}, -10); + graph.emplace_node(ptr); + THEN("The shape is fixed") { CHECK(ptr->ndim() == 1); CHECK(ptr->size() == 10); @@ -1985,6 +1988,46 @@ TEST_CASE("IntegerNode") { } } } + + AND_WHEN("We checkpoint the state and then mutate") { + auto checkpoint = ptr->checkpoint(state); // [-4, -4, -2, -2, 0, 0, 2, 2, 4, 4] + + ptr->exchange(state, 0, 2); // [-2, -4, -4, -2, 0, 0, 2, 2, 4, 4] + ptr->set_value(state, 3, 1); // [-2, -4, -4, 1, 0, 0, 2, 2, 4, 4] + + THEN("We can commit, then assign from the checkpoint") { + graph.propose(state); + + ptr->assign_from_checkpoint(state, checkpoint); + + CHECK_THAT(ptr->view(state), RangeEquals({-4, -4, -2, -2, 0, 0, 2, 2, 4, 4})); + } + } + + AND_WHEN("We mutate, checkpoint the state, and then mutate again") { + ptr->set_value(state, 3, 1); // [-4, -4, -2, 1, 0, 0, 2, 2, 4, 4] + + auto checkpoint = ptr->checkpoint(state); + + ptr->exchange(state, 0, 2); // [-2, -4, -4, 1, 0, 0, 2, 2, 4, 4] + + THEN("We can commit, then assign from the checkpoint") { + graph.propose(state); + + ptr->assign_from_checkpoint(state, checkpoint); + + CHECK_THAT(ptr->view(state), RangeEquals({-4, -4, -2, 1, 0, 0, 2, 2, 4, 4})); + } + + THEN("We can revert, then assign from the checkpoint") { + graph.propagate(state); + graph.revert(state); + + ptr->assign_from_checkpoint(state, checkpoint); + + CHECK_THAT(ptr->view(state), RangeEquals({-4, -4, -2, 1, 0, 0, 2, 2, 4, 4})); + } + } } } @@ -2195,6 +2238,8 @@ TEST_CASE("IntegerNode") { std::initializer_list{2, 2, 2}, -5, 8, sum_constraints ); + graph.emplace_node(inode_ptr); + THEN("Sum constraint is correct") { CHECK(inode_ptr->sum_constraints().size() == 1); SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; @@ -2209,14 +2254,110 @@ TEST_CASE("IntegerNode") { auto state = graph.initialize_state(); graph.initialize_state(state); std::vector expected_init{8, 8, 8, 8, 8, 8, -3, -5}; - auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); - CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 1); - CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({40})); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({40})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } + + AND_WHEN("We create a checkpoint and then mutate the state") { + auto checkpoint = inode_ptr->checkpoint(state); + + inode_ptr->set_value(state, 7, 3); // [ 8, 8, 8, 8, 8, 8, -3, 3 ] + inode_ptr->exchange(state, 1, 6); // [ 8, -3, 8, 8, 8, 8, 8, 3 ] + + THEN("After committing, We can revert to that checkpoint") { + graph.propose(state); + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({40})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); + } + } + + AND_WHEN("We mutate, create a checkpoint, and then mutate some more") { + inode_ptr->set_value(state, 7, 3); // [ 8, 8, 8, 8, 8, 8, -3, 3 ] + auto checkpoint = inode_ptr->checkpoint(state); + inode_ptr->exchange(state, 1, 6); // [ 8, -3, 8, 8, 8, 8, 8, 3 ] + + THEN("After committing, we can assign from that checkpoint") { + graph.propose(state); + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({48})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({8, 8, 8, 8, 8, 8, -3, 3})); + } + + THEN("After reverting, we can assign from that checkpoint") { + graph.propagate(state); + graph.revert(state); + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({48})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({8, 8, 8, 8, 8, 8, -3, 3})); + } + + AND_WHEN("We create a new checkpoint") { + auto checkpoint1 = inode_ptr->checkpoint(state); + + THEN("We can commit, and restore the checkpoints") { + inode_ptr->exchange(state, 1, 2); // [ 8, 8, -3, 8, 8, 8, 8, 3 ] + graph.propose(state); + + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint1)); + + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({48})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({8, -3, 8, 8, 8, 8, 8, 3})); + + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + + sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({48})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({8, 8, 8, 8, 8, 8, -3, 3})); + } + + THEN("We can revert, and restore the checkpoints") { + inode_ptr->exchange(state, 1, 2); // [ 8, 8, -3, 8, 8, 8, 8, 3 ] + graph.propagate(state); + graph.revert(state); + + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint1)); + + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({48})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({8, -3, 8, 8, 8, 8, 8, 3})); + + inode_ptr->assign_from_checkpoint(state, std::move(checkpoint)); + + sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); + CHECK(sum_constraints_lhs.size() == 1); + CHECK(sum_constraints_lhs.data()[0].size() == 1); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({48})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({8, 8, 8, 8, 8, 8, -3, 3})); + } + } + } } } From e61a70f7e041e31540b6ddd4994e29ad3d418508 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 27 Jul 2026 09:05:41 -0700 Subject: [PATCH 10/37] Fix assigning from a checkpoint after mutation --- dwave/optimization/src/nodes/_state.hpp | 2 +- dwave/optimization/src/nodes/collections.cpp | 19 +++++++++++ dwave/optimization/src/nodes/numbers.cpp | 34 ++++++++++++++++---- tests/cpp/nodes/test_collections.cpp | 24 ++++++++++++-- tests/cpp/nodes/test_numbers.cpp | 9 ++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/dwave/optimization/src/nodes/_state.hpp b/dwave/optimization/src/nodes/_state.hpp index b5e4d812..5a2da6cb 100644 --- a/dwave/optimization/src/nodes/_state.hpp +++ b/dwave/optimization/src/nodes/_state.hpp @@ -181,7 +181,7 @@ class ArrayStateData { std::swap(updates, tmp); // AlexC: we could now do updates.reserve(tmp.size()) under the assumption // that future update buffers will be a similar size. On the other hand, - // not doing this provides another meaningful difference to ::commit(). + // not doing this provides another meaningful difference to ::revert(). // For now, I think it make sense to not but performance testing needed. assert(updates.empty()); diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index fb265ed6..a08f3a09 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -133,6 +133,25 @@ class CollectionStateData_ : public NodeStateData, public CheckpointableState { // we need right now. assert(this->checkpoint_ptr() == checkpoint_ptr); + // Check if there are any changes not otherwise tracked by a checkpoint that we need + // to revert first. + // A better way would be to implement a partial revert on our state class, but this + // is not a path we care about greatly so let's err on the side of simple and well- + // tested. + if (ssize_t excess_updates = all_updates_.size() - checkpoint_ptr->drop()) { + assert(excess_updates > 0); // should never be negative + + // need a copy because we'll be mutating all_updates_ in the loop + auto excess_view = + all_updates_ | std::views::reverse | std::views::take(excess_updates); + std::vector excess(excess_view.begin(), excess_view.end()); + for (const auto& [idx, old, _] : excess) { + all_updates_.emplace_back(idx, elements_[idx], old); + if (idx < size_) updates_.emplace_back(idx, elements_[idx], old); + elements_[idx] = old; + } + } + // Ok, let's get ourselves to the same place as the checkpoint // we want to minimize the size of the visible buffer, so let's shrink ourselves diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 30920d53..e0b10673 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -108,14 +108,18 @@ class NumberNodeCheckpoint_ : public DiffCheckpoint { assert(this->drop() == 0); } - auto detach_slice_cache() { + auto detach_updates() { + auto updates = DiffCheckpoint::detach_updates(); + using join_type = decltype(std::move(slice_caches_) | std::views::join); - if (slice_caches_.empty()) return std::optional(); + if (slice_caches_.empty()) { + return std::make_tuple(std::move(updates), std::optional()); + } auto joined = std::move(slice_caches_) | std::views::join; assert(slice_caches_.empty()); - return std::optional(std::move(joined)); + return std::make_tuple(std::move(updates), std::optional(std::move(joined))); } void revert_updates(std::vector updates, slice_cache_type slice_cache) { @@ -1052,13 +1056,27 @@ void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoi // todo: assert that this checkpoint is the latest - auto updates = checkpoint_ptr->detach_updates(); - auto slice_cache = checkpoint_ptr->detach_slice_cache(); // this is an std::optional<...>! + // Check if there are any changes not otherwise tracked by a checkpoint that we need + // to revert first. + // A better way would be to implement a partial revert on our state class, but this + // is not a path we care about greatly so let's err on the side of simple and well- + // tested. + if (ssize_t excess_updates = state_data->diff().size() - checkpoint_ptr->drop()) { + assert(excess_updates > 0); + for ( + const auto& [idx, old, _] : + state_data->diff() | std::views::reverse | std::views::take(excess_updates) + ) { + state_data->set(idx, old); + } + } - if (slice_cache.has_value()) { + auto [updates, optional_slice_cache] = checkpoint_ptr->detach_updates(); + + if (optional_slice_cache.has_value()) { assert(sum_constraints_.size() > 0); - auto slices_rit = std::ranges::rbegin(*slice_cache); + auto slices_rit = std::ranges::rbegin(*optional_slice_cache); for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { state_data->set(idx, old); @@ -1072,6 +1090,8 @@ void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoi state_data->set(idx, old); } } + + checkpoint_ptr->drop() = state_data->diff().size(); } void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const { diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index b7820e5f..7c2f01d1 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -794,6 +794,16 @@ TEST_CASE("SetNode") { // this is a smoke test because there is no public way to check // that the checkpoint didn't get copied over } + + THEN("We can commit, mutate, then revert") { + graph.propose(state); + + set_ptr->exchange(state, 1, 2); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint0)); + + // CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + } } WHEN("We mutate the state and then create a checkpoint before commiting") { @@ -853,6 +863,8 @@ TEST_CASE("SetNode") { } } + // TODO: within one propagation + WHEN("We do several mutations and create several checkpoints within the same commit") { auto checkpoint0 = set_ptr->checkpoint(state); @@ -873,8 +885,6 @@ TEST_CASE("SetNode") { graph.propose(state); THEN("we can go backwards through them without commiting and everything is correct") { - // TODO: check mutating before assigning - set_ptr->assign_from_checkpoint(state, std::move(checkpoint4)); CHECK_THAT(set_ptr->view(state), RangeEquals({2})); @@ -926,6 +936,16 @@ TEST_CASE("SetNode") { CHECK_THAT(set_ptr->view(state), RangeEquals({4, 1})); } + THEN("We can assign from a checkpoint, mutate, and then assign again") { + set_ptr->assign_from_checkpoint(state, std::move(checkpoint4)); + CHECK_THAT(set_ptr->view(state), RangeEquals({2})); + + set_ptr->assign(state, {3, 0, 4}); + + set_ptr->assign_from_checkpoint(state, std::move(checkpoint3)); + CHECK_THAT(set_ptr->view(state), RangeEquals({4, 2})); + } + WHEN("We do some fuzzing with checkpoints") { auto rng = std::default_random_engine(); diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 0e10ae0d..5d28e3ea 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -2002,6 +2002,15 @@ TEST_CASE("IntegerNode") { CHECK_THAT(ptr->view(state), RangeEquals({-4, -4, -2, -2, 0, 0, 2, 2, 4, 4})); } + + THEN("We can commit, mutate, then assign from the checkpoint") { + graph.propose(state); + + ptr->set_value(state, 9, 0); // [-2, -4, -4, 1, 0, 0, 2, 2, 4, 0] + ptr->assign_from_checkpoint(state, checkpoint); + + CHECK_THAT(ptr->view(state), RangeEquals({-4, -4, -2, -2, 0, 0, 2, 2, 4, 4})); + } } AND_WHEN("We mutate, checkpoint the state, and then mutate again") { From e845381119902ce17826a24031abdc4246ed5ce2 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 27 Jul 2026 09:19:23 -0700 Subject: [PATCH 11/37] Add test for BinaryNode::assign_from_checkpoint --- dwave/optimization/src/nodes/collections.cpp | 23 ++++++++++++-------- tests/cpp/nodes/test_numbers.cpp | 12 ++++++++++ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index a08f3a09..11e122fc 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -145,10 +145,10 @@ class CollectionStateData_ : public NodeStateData, public CheckpointableState { auto excess_view = all_updates_ | std::views::reverse | std::views::take(excess_updates); std::vector excess(excess_view.begin(), excess_view.end()); + + // now do the mutation for (const auto& [idx, old, _] : excess) { - all_updates_.emplace_back(idx, elements_[idx], old); - if (idx < size_) updates_.emplace_back(idx, elements_[idx], old); - elements_[idx] = old; + set_(idx, old); } } @@ -159,12 +159,7 @@ class CollectionStateData_ : public NodeStateData, public CheckpointableState { while (size_ > checkpoint_ptr->size()) shrink(); for (const auto& [idx, old, _] : checkpoint_ptr->detach_updates() | std::views::reverse) { - if (elements_[idx] == old) continue; // nothing to do - - all_updates_.emplace_back(idx, elements_[idx], old); - if (idx < size_) updates_.emplace_back(idx, elements_[idx], old); - - elements_[idx] = old; + set_(idx, old); } // now that we've filled in our buffer, grow until we're the correct size @@ -282,6 +277,16 @@ class CollectionStateData_ : public NodeStateData, public CheckpointableState { ssize_t size_diff() const { return size_ - previous_size_; } private: + void set_(ssize_t index, double value) { + assert(0 <= index and static_cast(index) < elements_.size()); + + if (elements_[index] == value) return; + + all_updates_.emplace_back(index, elements_[index], value); + if (index < size_) updates_.emplace_back(index, elements_[index], value); + elements_[index] = value; + } + friend CollectionCheckpoint_; // The elements in the collection diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 5d28e3ea..45d228b0 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -295,6 +295,18 @@ TEST_CASE("BinaryNode") { CHECK(static_cast(ptr->diff(state).size()) == 2 * exchange_count_ground); } } + + AND_WHEN("We create a checkpoint to that state") { + auto checkpoint = ptr->checkpoint(state); // 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 + + THEN("We can mutate and then assign from that checkpoint") { + ptr->set_value(state, 0, 1); // 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 + graph.propose(state); + + ptr->assign_from_checkpoint(state, checkpoint); + CHECK_THAT(ptr->view(state), RangeEquals({0, 1, 0, 1, 0, 1, 0, 1, 0, 1})); + } + } } } From 4402568ac179067ae275345fa8b51b1ec11b5665 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 27 Jul 2026 11:45:24 -0700 Subject: [PATCH 12/37] Make all DecisionNodes implement checkpointing --- .../include/dwave-optimization/graph.hpp | 7 ++ .../dwave-optimization/nodes/collections.hpp | 24 ++++- .../dwave-optimization/nodes/numbers.hpp | 9 +- .../dwave-optimization/nodes/testing.hpp | 22 +++++ dwave/optimization/src/nodes/collections.cpp | 92 ++++++++++++++++++- ...eature-checkpointing-b770d2f2b66f648d.yaml | 2 +- tests/cpp/nodes/test_collections.cpp | 87 ++++++++++++++++++ 7 files changed, 231 insertions(+), 12 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index b05baa0e..ffd32fb1 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -436,6 +436,13 @@ class DecisionNode : public Decision, public virtual Node { /// Decision nodes by definition do not have a deterministic state. bool deterministic_state() const final { return false; } + /// Set the current state to match the one at the time the given checkpoint was created. + virtual void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const = 0; + virtual void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const = 0; + + /// Get a checkpoint, an IOU that can be used to return the node to its current state. + virtual checkpoint_type checkpoint(State& state) const = 0; + /// Decisions don't have predecessors so no one should be calling update(). /// Always throws a logic_error. [[noreturn]] void update(State& state, int index) const override; diff --git a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp index 68aeafae..c1c17122 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp @@ -32,14 +32,14 @@ class CollectionNode : public ArrayOutputMixin, public DecisionNode { // Set the node's state, tracking the diff. void assign(State& state, std::vector values) const; - /// Set the current state to match the one at the time the given checkpoint was created. - void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const; - void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const; + /// @copydoc DecisionNode::assign_from_checkpoint() + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const override; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const override; const double* buff(const State& state) const override; - /// Get a checkpoint, an IOU that can be used to return the node to its current state. - checkpoint_type checkpoint(State& state) const; + /// @copydoc DecisionNode::checkpoint() + checkpoint_type checkpoint(State& state) const override; void commit(State&) const override; @@ -107,6 +107,13 @@ class DisjointBitSetsNode : public DecisionNode { // i.e. the set `range(primary_set_size)`. DisjointBitSetsNode(ssize_t primary_set_size, ssize_t num_disjoint_sets); + /// @copydoc DecisionNode::assign_from_checkpoint() + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const override; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const override; + + /// @copydoc DecisionNode::checkpoint() + checkpoint_type checkpoint(State& state) const override; + void commit(State&) const override; ssize_t get_containing_set_index(State& state, ssize_t element_i) const; @@ -179,6 +186,13 @@ class DisjointListsNode : public DecisionNode { // i.e. the set `range(primary_set_size)`. DisjointListsNode(ssize_t primary_set_size, ssize_t num_disjoint_lists); + /// @copydoc DecisionNode::assign_from_checkpoint() + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const override; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const override; + + /// @copydoc DecisionNode::checkpoint() + checkpoint_type checkpoint(State& state) const override; + void commit(State&) const override; ssize_t get_disjoint_list_size(State& state, ssize_t list_index) const; diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 34cc9069..44a08bc6 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -121,7 +121,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // NumberNode methods ***************************************************** - std::unique_ptr checkpoint(State& state) const; + /// @copydoc DecisionNode::checkpoint() + checkpoint_type checkpoint(State& state) const override; // In the given state, swap the value of index i with the value of index j. // Users may pass the slices (per sum constraint) that each index lies on. @@ -291,9 +292,9 @@ class IntegerNode : public NumberNode { // IntegerNode methods **************************************************** - /// Set the current state to match the one at the time the given checkpoint was created. - void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const; - void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const; + /// @copydoc DecisionNode::assign_from_checkpoint() + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const override; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const override; // Set the value at the given index in the given state. // Users may pass the slices (per sum constraint) that each index lies on. diff --git a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp index 53e08a61..16179ead 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp @@ -100,6 +100,28 @@ class DynamicArrayTestingNode : public ArrayOutputMixin, public Decis void revert(State&) const override; void update(State&, int) const override; + // Overloads required by the DecisionNode ABC ***************************** + + // DynamicArrayTestingNode does not impement checkpointing + [[noreturn]] void assign_from_checkpoint( + State& state, + checkpoint_type& checkpoint + ) const override { + assert(false and "not implemented"); + unreachable(); + } + [[noreturn]] void assign_from_checkpoint( + State& state, + checkpoint_type&& checkpoint + ) const override { + assert(false and "not implemented"); + unreachable(); + } + [[noreturn]] virtual checkpoint_type checkpoint(State& state) const override { + assert(false and "not implemented"); + unreachable(); + } + // State mutation methods ************************************************* // Grow the array by a single row of the given values. diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 11e122fc..1bb52235 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -440,7 +440,17 @@ ssize_t CollectionNode::size_diff(const State& state) const { return data_ptr_(state)->size_diff(); } -struct DisjointBitSetsNodeData_ : NodeStateData { +// DisjointBitSetsNode is on the way out, so let's do the simplest possible +// implementation for now. +class DisjointBitSetsCheckpoint_ : public LinkedListCheckpoint { + public: + DisjointBitSetsCheckpoint_(CheckpointableState& state, const std::ranges::range auto& buff) : + LinkedListCheckpoint(state), buffer(buff.begin(), buff.end()) {} + + std::vector buffer; +}; + +struct DisjointBitSetsNodeData_ : CheckpointableState, NodeStateData { DisjointBitSetsNodeData_(ssize_t primary_set_size, ssize_t num_disjoint_sets) : primary_set_size(primary_set_size), num_disjoint_sets(num_disjoint_sets) { data.resize(primary_set_size * num_disjoint_sets, 0); @@ -489,6 +499,21 @@ struct DisjointBitSetsNodeData_ : NodeStateData { } } + void assign(std::span buff) { + assert(data.size() == buff.size()); + + for (ssize_t disjoint_set = 0; disjoint_set < num_disjoint_sets; ++disjoint_set) { + const ssize_t start = disjoint_set * primary_set_size; + const ssize_t stop = start + primary_set_size; + for (ssize_t i = start; i < stop; ++i) { + if (data[i] != buff[i]) { + diffs[disjoint_set].emplace_back(i % primary_set_size, data[i], buff[i]); + data[i] = buff[i]; + } + } + } + } + void swap_between_sets(ssize_t from_disjoint_set, ssize_t to_disjoint_set, ssize_t element) { double& el0 = data[from_disjoint_set * primary_set_size + element]; double& el1 = data[to_disjoint_set * primary_set_size + element]; @@ -556,6 +581,28 @@ void DisjointBitSetsNode::initialize_state( ); } +void DisjointBitSetsNode::assign_from_checkpoint( + State& state, + std::unique_ptr& checkpoint +) const { + const DisjointBitSetsCheckpoint_* checkpoint_ptr = + static_cast(checkpoint.get()); + data_ptr_(state)->assign(checkpoint_ptr->buffer); +} + +void DisjointBitSetsNode::assign_from_checkpoint( + State& state, + std::unique_ptr&& checkpoint +) const { + assign_from_checkpoint(state, checkpoint); // use the lvalue version + checkpoint.reset(); +} + +std::unique_ptr DisjointBitSetsNode::checkpoint(State& state) const { + auto* state_ptr = data_ptr_(state); + return std::make_unique(*state_ptr, state_ptr->data); +} + void DisjointBitSetsNode::commit(State& state) const { data_ptr_(state)->commit(); } @@ -610,7 +657,20 @@ double DisjointBitSetNode::min() const { return 0; } double DisjointBitSetNode::max() const { return 1; } -struct DisjointListStateData_ : NodeStateData { +// DisjointListsNode is on the way out, so let's do the simplest possible +// implementation for now. +class DisjointListsCheckpoint_ : public LinkedListCheckpoint { + public: + DisjointListsCheckpoint_( + CheckpointableState& state, + const std::vector>& lists + ) : + LinkedListCheckpoint(state), lists(lists) {} + + std::vector> lists; +}; + +struct DisjointListStateData_ : CheckpointableState, NodeStateData { DisjointListStateData_(ssize_t primary_set_size, ssize_t num_disjoint_lists) : primary_set_size(primary_set_size) { lists.resize(num_disjoint_lists); @@ -840,6 +900,34 @@ DisjointListsNode::DisjointListsNode(ssize_t primary_set_size, ssize_t num_disjo if (num_disjoint_lists < 1) throw std::invalid_argument("num_disjoint_lists must be positive"); } +void DisjointListsNode::assign_from_checkpoint( + State& state, + std::unique_ptr& checkpoint +) const { + auto* state_ptr = data_ptr_(state); + + const DisjointListsCheckpoint_* checkpoint_ptr = + static_cast(checkpoint.get()); + + ssize_t list_index = 0; + for (const std::vector& list : checkpoint_ptr->lists) { + state_ptr->set_state(list_index++, list); + } +} + +void DisjointListsNode::assign_from_checkpoint( + State& state, + std::unique_ptr&& checkpoint +) const { + assign_from_checkpoint(state, checkpoint); // use the lvalue version + checkpoint.reset(); +} + +std::unique_ptr DisjointListsNode::checkpoint(State& state) const { + auto* state_ptr = data_ptr_(state); + return std::make_unique(*state_ptr, state_ptr->lists); +} + void DisjointListsNode::initialize_state(State& state) const { emplace_data_ptr_( state, this->primary_set_size(), this->num_disjoint_lists() diff --git a/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml b/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml index c82063e4..014fda90 100644 --- a/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml +++ b/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml @@ -3,5 +3,5 @@ features: - | Add a C++ ``Graph::propose(State&)`` overload that propagates and commits. - | - Add checkpointing to ``CollectionNode``. + Add checkpointing to all decision nodes. See `#510 `_. diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index 7c2f01d1..adceabdb 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -162,6 +162,47 @@ TEST_CASE("DisjointBitSetsNode") { CHECK(std::ranges::equal(sets[1]->view(state), std::vector{1, 0, 1, 0, 0})); CHECK(std::ranges::equal(sets[2]->view(state), std::vector{0, 1, 0, 1, 0})); } + + AND_WHEN("We create a checkpoint to that state") { + auto checkpoint = ptr->checkpoint(state); + + THEN("We can mutate and then assign from that checkpoint") { + ptr->swap_between_sets(state, 0, 1, 0); + graph.propose(state); + + ptr->assign_from_checkpoint(state, std::move(checkpoint)); + assert(not checkpoint); // was reset + + CHECK_THAT(sets[0]->view(state), RangeEquals({0, 0, 0, 0, 1})); + CHECK_THAT(sets[1]->view(state), RangeEquals({1, 0, 1, 0, 0})); + } + + THEN("We can assign, mutate, and then reuse the checkpoint") { + ptr->swap_between_sets(state, 0, 1, 0); + CHECK_THAT(sets[0]->view(state), RangeEquals({1, 0, 0, 0, 1})); + CHECK_THAT(sets[1]->view(state), RangeEquals({0, 0, 1, 0, 0})); + CHECK_THAT(sets[2]->view(state), RangeEquals({0, 1, 0, 1, 0})); + + graph.propose(state); + + ptr->assign_from_checkpoint(state, checkpoint); + CHECK_THAT(sets[0]->view(state), RangeEquals({0, 0, 0, 0, 1})); + CHECK_THAT(sets[1]->view(state), RangeEquals({1, 0, 1, 0, 0})); + CHECK_THAT(sets[2]->view(state), RangeEquals({0, 1, 0, 1, 0})); + + ptr->swap_between_sets(state, 1, 2, 1); + CHECK_THAT(sets[0]->view(state), RangeEquals({0, 0, 0, 0, 1})); + CHECK_THAT(sets[1]->view(state), RangeEquals({1, 1, 1, 0, 0})); + CHECK_THAT(sets[2]->view(state), RangeEquals({0, 0, 0, 1, 0})); + + graph.propose(state); + + ptr->assign_from_checkpoint(state, std::move(checkpoint)); + CHECK_THAT(sets[0]->view(state), RangeEquals({0, 0, 0, 0, 1})); + CHECK_THAT(sets[1]->view(state), RangeEquals({1, 0, 1, 0, 0})); + CHECK_THAT(sets[2]->view(state), RangeEquals({0, 1, 0, 1, 0})); + } + } } AND_WHEN("We initialize an empty state") { @@ -322,6 +363,52 @@ TEST_CASE("DisjointListsNode") { CHECK(std::ranges::equal(lists[1]->view(state), std::vector{2, 0})); CHECK(std::ranges::equal(lists[2]->view(state), std::vector{1, 3})); } + + AND_WHEN("We create a checkpoint to that state") { + auto checkpoint = ptr->checkpoint(state); + + THEN("We can mutate and then assign from that checkpoint") { + ptr->pop_to_list(state, 1, 0, 0, 1); + CHECK_THAT(lists[0]->view(state), RangeEquals({4, 2})); + CHECK_THAT(lists[1]->view(state), RangeEquals({0})); + CHECK_THAT(lists[2]->view(state), RangeEquals({1, 3})); + + graph.propose(state); + + ptr->assign_from_checkpoint(state, std::move(checkpoint)); + assert(not checkpoint); // was reset + + CHECK_THAT(lists[0]->view(state), RangeEquals({4})); + CHECK_THAT(lists[1]->view(state), RangeEquals({2, 0})); + CHECK_THAT(lists[2]->view(state), RangeEquals({1, 3})); + } + + THEN("We can assign, mutate, and then reuse the checkpoint") { + ptr->pop_to_list(state, 1, 0, 0, 1); + CHECK_THAT(lists[0]->view(state), RangeEquals({4, 2})); + CHECK_THAT(lists[1]->view(state), RangeEquals({0})); + CHECK_THAT(lists[2]->view(state), RangeEquals({1, 3})); + + graph.propose(state); + + ptr->assign_from_checkpoint(state, checkpoint); + CHECK_THAT(lists[0]->view(state), RangeEquals({4})); + CHECK_THAT(lists[1]->view(state), RangeEquals({2, 0})); + CHECK_THAT(lists[2]->view(state), RangeEquals({1, 3})); + + ptr->pop_to_list(state, 1, 1, 2, 2); + CHECK_THAT(lists[0]->view(state), RangeEquals({4})); + CHECK_THAT(lists[1]->view(state), RangeEquals({2})); + CHECK_THAT(lists[2]->view(state), RangeEquals({1, 3, 0})); + + graph.propose(state); + + ptr->assign_from_checkpoint(state, std::move(checkpoint)); + CHECK_THAT(lists[0]->view(state), RangeEquals({4})); + CHECK_THAT(lists[1]->view(state), RangeEquals({2, 0})); + CHECK_THAT(lists[2]->view(state), RangeEquals({1, 3})); + } + } } THEN("We get an error when trying to initialize invalid partitions") { From ec4abfc8999f02c9c9bdea3bd846087b6c822a6e Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 4 Aug 2026 10:51:47 -0700 Subject: [PATCH 13/37] Expand docstrings for checkpoint implementations --- dwave/optimization/src/nodes/_checkpoints.hpp | 72 +++++++++++++------ 1 file changed, 50 insertions(+), 22 deletions(-) diff --git a/dwave/optimization/src/nodes/_checkpoints.hpp b/dwave/optimization/src/nodes/_checkpoints.hpp index 8e1866d7..f6187ca0 100644 --- a/dwave/optimization/src/nodes/_checkpoints.hpp +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -23,13 +23,42 @@ namespace dwave::optimization { -class LinkedListCheckpoint; +class CheckpointableState; +// A LinkedListCheckpoint is one checkpoint in a chain of checkpoints implemented +// as a doubly-linked list. +class LinkedListCheckpoint : public NodeStateCheckpoint { + public: + LinkedListCheckpoint() = delete; + // We're not moveable or copy-able because NodeStateCheckpoint is not. + + LinkedListCheckpoint(CheckpointableState& state); + + ~LinkedListCheckpoint() override; + + protected: + friend CheckpointableState; + + // The next-oldest checkpoint in the chain. Can be nullptr which indicates + // that this is the oldest checkpoint. + LinkedListCheckpoint* prev_ptr_; + + // The next-newest checkpoint in the chain or, if this is the newest + // newest checkpoint, will point to the node state. + // Is usually not nullptr unless the state has been destructed before the + // checkpoint has. + std::variant next_ptr_; +}; + +// A mixin class for states to work with LinkedListCheckpoints. class CheckpointableState { public: CheckpointableState() = default; - CheckpointableState(const CheckpointableState&) {} // the checkpoint pointer is not copied + // When CheckpointableState is copied, we don't want the new state to inherit + // its checkpoints. + CheckpointableState(const CheckpointableState&) {} + CheckpointableState(CheckpointableState&&) = default; CheckpointableState& operator=(const CheckpointableState&) = delete; @@ -43,55 +72,54 @@ class CheckpointableState { return static_cast(prev_ptr_); } - private: // todo: private? + private: friend LinkedListCheckpoint; // The name is a bit confusing, but by making it match LinkedListCheckpoint::prev_ptr_ // it makes the implementations of the various visit methods clearer. - LinkedListCheckpoint* prev_ptr_ = nullptr; // Will be nullptr if there are no checkpoints -}; - -class LinkedListCheckpoint : public NodeStateCheckpoint { - public: - LinkedListCheckpoint() = delete; - // We're not moveable or copyable because NodeStateCheckpoint is not. - - LinkedListCheckpoint(CheckpointableState& state); - - ~LinkedListCheckpoint() override; - - protected: // todo: private? - friend CheckpointableState; - - LinkedListCheckpoint* prev_ptr_; - - // Is usually not nullptr unless the state has been destructed - std::variant next_ptr_; + // Will be nullptr if there are no checkpoints + LinkedListCheckpoint* prev_ptr_ = nullptr; }; +// A DiffCheckpoint is a type of linked list checkpoint that stores the diffs +// since it was created. class DiffCheckpoint : public LinkedListCheckpoint { public: DiffCheckpoint(CheckpointableState& state, std::span diff); ~DiffCheckpoint() override; + // Add updates associated with a commit to the checkpoint. The checkpoint + // therefore stores the information it needs to later undo those changes. void commit_updates(std::vector updates); + // Clear all the updates held by the checkpoint and return them to the + // caller. auto detach_updates() { auto updates = std::move(updates_) | std::views::join; assert(updates_.empty()); return updates; } + // The current "drop". The drop is used when a checkpoint is created while + // a node has some mutations already applied. This tells the checkpoint + // how to handle the diff associated with those mutations, i.e., the ones + // the checkpoint shouldn't be tracking. ssize_t& drop() { return drop_; } + // Add updates associated with a revert to the checkpoint. The checkpoint + // therefore stores the information it needs to later undo those changes. void revert_updates(std::vector updates); protected: DiffCheckpoint(CheckpointableState& state, ssize_t drop); private: + // We store the updates as a vector-of-vectors in order to make them fast + // to append. std::vector> updates_; + + // See drop() docstring. ssize_t drop_; }; From 9ed9e4c00648a7aca3676e78b012b19b15a535fc Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 4 Aug 2026 15:53:55 -0700 Subject: [PATCH 14/37] Add Graph::pop_decision() and Graph::swap_decisions() methods --- .../include/dwave-optimization/graph.hpp | 12 +++- dwave/optimization/src/graph.cpp | 43 ++++++++++++++ ...ph-decision-mutation-40cd6f2d32724734.yaml | 3 + tests/cpp/test_graph.cpp | 56 +++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 releasenotes/notes/graph-decision-mutation-40cd6f2d32724734.yaml diff --git a/dwave/optimization/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index e52b56df..9f082931 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -135,6 +135,10 @@ class Graph { ArrayNode* objective() noexcept { return objective_ptr_; } const ArrayNode* objective() const noexcept { return objective_ptr_; } + /// Remove the last decision. Must not have any successors or the behavior + /// is undefined. + void pop_decision(); + /// Call propagate on every `Node` in the `Graph`. void propagate(State& state) const; @@ -196,6 +200,9 @@ class Graph { /// To unset the objective provide nullptr. void set_objective(ArrayNode* objective_ptr); + /// Swap the topological indices of the two given decision nodes. + void swap_decisions(DecisionNode* x_ptr, DecisionNode* y_ptr); + /// Sort the nodes topologically. This "locks" the model in that nodes cannot /// be added to a topologically sorted model without invalidating the topological /// ordering. @@ -340,12 +347,13 @@ class Node { /// Nodes are printable friend std::ostream& operator<<(std::ostream& os, const Node& node); - friend void Graph::topological_sort(); - friend void Graph::reset_topological_sort(); template friend NodeType* Graph::emplace_node(Args&&...); friend ssize_t Graph::remove_redundant_nodes(bool, double); friend ssize_t Graph::remove_unused_nodes(bool); + friend void Graph::reset_topological_sort(); + friend void Graph::swap_decisions(DecisionNode* x_ptr, DecisionNode* y_ptr); + friend void Graph::topological_sort(); protected: // For use by non-dynamic node constructors. diff --git a/dwave/optimization/src/graph.cpp b/dwave/optimization/src/graph.cpp index 908acc8a..07d00b63 100644 --- a/dwave/optimization/src/graph.cpp +++ b/dwave/optimization/src/graph.cpp @@ -157,6 +157,23 @@ void Graph::initialize_state(State& state) { static_cast(this)->initialize_state(state); } +void Graph::pop_decision() { + assert(not topologically_sorted_ and "cannot pop a decision from a locked model"); + + // Get the index of the node we're going to delete + const ssize_t i = std::ranges::ssize(decisions_) - 1; + assert(0 <= i and "need at least one decision"); + + // Check that nodes_ and decisions_ are consistent (should always be true) + assert(nodes_[i].get() == decisions_[i]); + + // Confirm that removing the decision won't leave anything dangling + assert(decisions_[i]->successors().empty() and "cannot remove a decision with successors"); + + decisions_.pop_back(); + nodes_.erase(nodes_.begin() + i); +} + void Graph::propagate(State& state) const { std::ranges::for_each(nodes(), [&state](const auto& ptr) { ptr->propagate(state); }); } @@ -545,6 +562,32 @@ void Graph::set_objective(ArrayNode* objective_ptr) { this->objective_ptr_ = objective_ptr; } +void Graph::swap_decisions(DecisionNode* x_ptr, DecisionNode* y_ptr) { + assert(not topologically_sorted_ and "cannot swap decisions in a locked model"); + + if (x_ptr == y_ptr) return; // nothing to do + + ssize_t& x_idx = x_ptr->topological_index_; + ssize_t& y_idx = y_ptr->topological_index_; + + assert(0 <= x_idx and static_cast(x_idx) < decisions_.size()); + assert(0 <= y_idx and static_cast(y_idx) < decisions_.size()); + + assert(decisions_[x_idx] == x_ptr); + assert(decisions_[y_idx] == y_ptr); + + assert(0 <= x_idx and static_cast(x_idx) < nodes_.size()); + assert(0 <= y_idx and static_cast(y_idx) < nodes_.size()); + + assert(nodes_[x_idx].get() == static_cast(x_ptr)); + assert(nodes_[y_idx].get() == static_cast(y_ptr)); + + using std::swap; // ADL shouldn't matter here, but a good habit nonetheless + swap(nodes_[x_idx], nodes_[y_idx]); + swap(decisions_[x_idx], decisions_[y_idx]); + swap(x_idx, y_idx); +} + void Graph::topological_sort() { if (topologically_sorted_) return; diff --git a/releasenotes/notes/graph-decision-mutation-40cd6f2d32724734.yaml b/releasenotes/notes/graph-decision-mutation-40cd6f2d32724734.yaml new file mode 100644 index 00000000..bb4af7fc --- /dev/null +++ b/releasenotes/notes/graph-decision-mutation-40cd6f2d32724734.yaml @@ -0,0 +1,3 @@ +--- +features: + - Add ``Graph::pop_decision()`` and ``Graph::swap_decisions()`` methods. diff --git a/tests/cpp/test_graph.cpp b/tests/cpp/test_graph.cpp index d1975d98..26c53199 100644 --- a/tests/cpp/test_graph.cpp +++ b/tests/cpp/test_graph.cpp @@ -318,6 +318,30 @@ TEST_CASE("Graph::objective()") { } } +TEST_CASE("Graph::pop_decision()") { + GIVEN("A graph with three decisions and an intermediate node") { + auto graph = Graph(); + + auto* x_ptr = graph.emplace_node(); + auto* y_ptr = graph.emplace_node(); + graph.emplace_node(5); + + auto* a_ptr = graph.emplace_node(x_ptr, y_ptr); + + graph.pop_decision(); + + graph.topological_sort(); + + CHECK(graph.nodes()[0].get() == x_ptr); + CHECK(graph.nodes()[1].get() == y_ptr); + CHECK(graph.nodes()[2].get() == a_ptr); + + CHECK(x_ptr->topological_index() == 0); + CHECK(y_ptr->topological_index() == 1); + CHECK(a_ptr->topological_index() == 2); + } +} + TEST_CASE("Graph::remove_redundant_nodes()") { GIVEN("A model with two redundant nodes") { auto graph = Graph(); @@ -501,4 +525,36 @@ TEST_CASE("Graph::remove_unused_nodes()") { } } +TEST_CASE("Graph::swap_decisions") { + GIVEN("A graph with three decisions") { + auto graph = Graph(); + + DecisionNode* x_ptr = graph.emplace_node(); + DecisionNode* y_ptr = graph.emplace_node(); + DecisionNode* z_ptr = graph.emplace_node(5); + + CHECK_THAT(graph.decisions(), RangeEquals({x_ptr, y_ptr, z_ptr})); + + CHECK(graph.nodes()[0].get() == x_ptr); + CHECK(graph.nodes()[1].get() == y_ptr); + CHECK(graph.nodes()[2].get() == z_ptr); + + CHECK(x_ptr->topological_index() == 0); + CHECK(y_ptr->topological_index() == 1); + CHECK(z_ptr->topological_index() == 2); + + graph.swap_decisions(y_ptr, z_ptr); + + CHECK_THAT(graph.decisions(), RangeEquals({x_ptr, z_ptr, y_ptr})); + + CHECK(graph.nodes()[0].get() == x_ptr); + CHECK(graph.nodes()[1].get() == z_ptr); + CHECK(graph.nodes()[2].get() == y_ptr); + + CHECK(x_ptr->topological_index() == 0); + CHECK(y_ptr->topological_index() == 2); + CHECK(z_ptr->topological_index() == 1); + } +} + } // namespace dwave::optimization From bcbda09ccca45e9250119f294e5dac7c89713bb5 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 4 Aug 2026 22:09:00 -0700 Subject: [PATCH 15/37] Make license headers consistent --- dwave/optimization/__init__.pxd | 2 +- dwave/optimization/__init__.py | 2 +- dwave/optimization/_build/_version.py | 2 +- dwave/optimization/_model.pxd | 2 +- dwave/optimization/_model.pyi | 2 +- dwave/optimization/_model.pyx | 2 +- dwave/optimization/generators.py | 2 +- dwave/optimization/include/dwave-optimization/array.hpp | 2 +- dwave/optimization/include/dwave-optimization/graph.hpp | 2 +- dwave/optimization/include/dwave-optimization/nodes.hpp | 2 +- .../include/dwave-optimization/nodes/collections.hpp | 2 +- .../optimization/include/dwave-optimization/nodes/constants.hpp | 2 +- dwave/optimization/include/dwave-optimization/nodes/flow.hpp | 2 +- .../optimization/include/dwave-optimization/nodes/indexing.hpp | 2 +- dwave/optimization/include/dwave-optimization/nodes/lambda.hpp | 2 +- .../include/dwave-optimization/nodes/linear_algebra.hpp | 2 +- dwave/optimization/include/dwave-optimization/nodes/numbers.hpp | 2 +- .../include/dwave-optimization/nodes/quadratic_model.hpp | 2 +- dwave/optimization/include/dwave-optimization/nodes/testing.hpp | 2 +- dwave/optimization/include/dwave-optimization/state.hpp | 2 +- dwave/optimization/libcpp/__init__.pxd | 2 +- dwave/optimization/mathematical.py | 2 +- dwave/optimization/model.pxd | 2 +- dwave/optimization/model.py | 2 +- dwave/optimization/src/array.cpp | 2 +- dwave/optimization/src/graph.cpp | 2 +- dwave/optimization/src/nodes/_state.hpp | 2 +- dwave/optimization/src/nodes/collections.cpp | 2 +- dwave/optimization/src/nodes/flow.cpp | 2 +- dwave/optimization/src/nodes/indexing.cpp | 2 +- dwave/optimization/src/nodes/lambda.cpp | 2 +- dwave/optimization/src/nodes/linear_algebra.cpp | 2 +- dwave/optimization/src/nodes/numbers.cpp | 2 +- dwave/optimization/src/nodes/quadratic_model.cpp | 2 +- dwave/optimization/src/nodes/testing.cpp | 2 +- dwave/optimization/typing.py | 2 +- pyproject.toml | 2 +- tests/cpp/nodes/test_collections.cpp | 2 +- tests/cpp/nodes/test_constants.cpp | 2 +- tests/cpp/nodes/test_flow.cpp | 2 +- tests/cpp/nodes/test_interpolation.cpp | 2 +- tests/cpp/nodes/test_lambda.cpp | 2 +- tests/cpp/nodes/test_numbers.cpp | 2 +- tests/cpp/nodes/test_quadratic_model.cpp | 2 +- tests/cpp/test_array.cpp | 2 +- tests/cpp/test_graph.cpp | 2 +- tests/test_examples.py | 2 +- tests/test_generators.py | 2 +- tests/test_model.py | 2 +- tests/test_states.py | 2 +- tests/test_symbols.py | 2 +- 51 files changed, 51 insertions(+), 51 deletions(-) diff --git a/dwave/optimization/__init__.pxd b/dwave/optimization/__init__.pxd index 075977c1..62f3f3d4 100644 --- a/dwave/optimization/__init__.pxd +++ b/dwave/optimization/__init__.pxd @@ -1,4 +1,4 @@ -# Copyright 2023 D-Wave Systems Inc. +# Copyright 2023 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/__init__.py b/dwave/optimization/__init__.py index e22fd1a7..a9117049 100644 --- a/dwave/optimization/__init__.py +++ b/dwave/optimization/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2023 D-Wave Systems Inc. +# Copyright 2023 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/_build/_version.py b/dwave/optimization/_build/_version.py index 859af32e..62fd7845 100644 --- a/dwave/optimization/_build/_version.py +++ b/dwave/optimization/_build/_version.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/_model.pxd b/dwave/optimization/_model.pxd index 889afcae..c1244b72 100644 --- a/dwave/optimization/_model.pxd +++ b/dwave/optimization/_model.pxd @@ -1,6 +1,6 @@ # cython: auto_pickle=False -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/_model.pyi b/dwave/optimization/_model.pyi index e43e5811..2a42f830 100644 --- a/dwave/optimization/_model.pyi +++ b/dwave/optimization/_model.pyi @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/_model.pyx b/dwave/optimization/_model.pyx index ae030263..28355400 100644 --- a/dwave/optimization/_model.pyx +++ b/dwave/optimization/_model.pyx @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/generators.py b/dwave/optimization/generators.py index e95d9624..012479a2 100644 --- a/dwave/optimization/generators.py +++ b/dwave/optimization/generators.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/array.hpp b/dwave/optimization/include/dwave-optimization/array.hpp index 7182b3af..e94e211e 100644 --- a/dwave/optimization/include/dwave-optimization/array.hpp +++ b/dwave/optimization/include/dwave-optimization/array.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index e52b56df..ce71b374 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes.hpp b/dwave/optimization/include/dwave-optimization/nodes.hpp index 01efa8b5..52d86e04 100644 --- a/dwave/optimization/include/dwave-optimization/nodes.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp index ed317802..88b573bf 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/constants.hpp b/dwave/optimization/include/dwave-optimization/nodes/constants.hpp index 57efe7a9..389037c2 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/constants.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/constants.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/flow.hpp b/dwave/optimization/include/dwave-optimization/nodes/flow.hpp index a8e01cc9..3a9b2285 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/flow.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/flow.hpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/indexing.hpp b/dwave/optimization/include/dwave-optimization/nodes/indexing.hpp index e61cce7e..3232a55f 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/indexing.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/indexing.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/lambda.hpp b/dwave/optimization/include/dwave-optimization/nodes/lambda.hpp index bb1b4dc5..9be1fe81 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/lambda.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/lambda.hpp @@ -1,4 +1,4 @@ -// Copyright 2025 D-Wave Inc. +// Copyright 2025 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/linear_algebra.hpp b/dwave/optimization/include/dwave-optimization/nodes/linear_algebra.hpp index ec6260f6..379f0697 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/linear_algebra.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/linear_algebra.hpp @@ -1,4 +1,4 @@ -// Copyright 2025 D-Wave Inc. +// Copyright 2025 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 252e709f..3d5bc593 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Systems Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/quadratic_model.hpp b/dwave/optimization/include/dwave-optimization/nodes/quadratic_model.hpp index d8d2dbb6..64c95c20 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/quadratic_model.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/quadratic_model.hpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Systems Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp index 51fb0234..33374ed8 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Systems Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/include/dwave-optimization/state.hpp b/dwave/optimization/include/dwave-optimization/state.hpp index 884bf972..b094b8c4 100644 --- a/dwave/optimization/include/dwave-optimization/state.hpp +++ b/dwave/optimization/include/dwave-optimization/state.hpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/libcpp/__init__.pxd b/dwave/optimization/libcpp/__init__.pxd index 53b39109..6e642663 100644 --- a/dwave/optimization/libcpp/__init__.pxd +++ b/dwave/optimization/libcpp/__init__.pxd @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 0fe2e6c0..f3d650c3 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/model.pxd b/dwave/optimization/model.pxd index 8d0a63b9..6fb140a7 100644 --- a/dwave/optimization/model.pxd +++ b/dwave/optimization/model.pxd @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 2cfa787b..c6c9c889 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/array.cpp b/dwave/optimization/src/array.cpp index c0a20513..3484d30c 100644 --- a/dwave/optimization/src/array.cpp +++ b/dwave/optimization/src/array.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/graph.cpp b/dwave/optimization/src/graph.cpp index 908acc8a..cfe7ef06 100644 --- a/dwave/optimization/src/graph.cpp +++ b/dwave/optimization/src/graph.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/_state.hpp b/dwave/optimization/src/nodes/_state.hpp index bc715202..1e5db77b 100644 --- a/dwave/optimization/src/nodes/_state.hpp +++ b/dwave/optimization/src/nodes/_state.hpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index a673f119..45d0e94f 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/flow.cpp b/dwave/optimization/src/nodes/flow.cpp index 19f3e71c..060569aa 100644 --- a/dwave/optimization/src/nodes/flow.cpp +++ b/dwave/optimization/src/nodes/flow.cpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/indexing.cpp b/dwave/optimization/src/nodes/indexing.cpp index a7816f5b..48dc54ea 100644 --- a/dwave/optimization/src/nodes/indexing.cpp +++ b/dwave/optimization/src/nodes/indexing.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/lambda.cpp b/dwave/optimization/src/nodes/lambda.cpp index 34335efb..b40ec9a5 100644 --- a/dwave/optimization/src/nodes/lambda.cpp +++ b/dwave/optimization/src/nodes/lambda.cpp @@ -1,4 +1,4 @@ -// Copyright 2025 D-Wave Inc. +// Copyright 2025 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/linear_algebra.cpp b/dwave/optimization/src/nodes/linear_algebra.cpp index 94f85b43..2fe01806 100644 --- a/dwave/optimization/src/nodes/linear_algebra.cpp +++ b/dwave/optimization/src/nodes/linear_algebra.cpp @@ -1,4 +1,4 @@ -// Copyright 2025 D-Wave Inc. +// Copyright 2025 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 317082ed..da03f2d7 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Systems Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/quadratic_model.cpp b/dwave/optimization/src/nodes/quadratic_model.cpp index b31e21d5..05a1fe8c 100644 --- a/dwave/optimization/src/nodes/quadratic_model.cpp +++ b/dwave/optimization/src/nodes/quadratic_model.cpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Systems Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/src/nodes/testing.cpp b/dwave/optimization/src/nodes/testing.cpp index d2ca7485..1cfba3a2 100644 --- a/dwave/optimization/src/nodes/testing.cpp +++ b/dwave/optimization/src/nodes/testing.cpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Systems Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/dwave/optimization/typing.py b/dwave/optimization/typing.py index 0e44d52e..bbfd039e 100644 --- a/dwave/optimization/typing.py +++ b/dwave/optimization/typing.py @@ -1,4 +1,4 @@ -# Copyright 2025 D-Wave Inc. +# Copyright 2025 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/pyproject.toml b/pyproject.toml index 521a012d..ab59e4c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ build-backend = 'mesonpy' name = "dwave-optimization" dynamic = ["version"] authors = [ - {name = "D-Wave Inc.", email = "tools@dwavesys.com"}, + {name = "D-Wave", email = "tools@dwavesys.com"}, ] description = "Enables the formulation of nonlinear models for industrial optimization problems." license = "Apache-2.0" diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index 3f1c275c..61fff142 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/nodes/test_constants.cpp b/tests/cpp/nodes/test_constants.cpp index 6cd1bc4c..6dcb9a22 100644 --- a/tests/cpp/nodes/test_constants.cpp +++ b/tests/cpp/nodes/test_constants.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/nodes/test_flow.cpp b/tests/cpp/nodes/test_flow.cpp index bebe3d75..b1efa548 100644 --- a/tests/cpp/nodes/test_flow.cpp +++ b/tests/cpp/nodes/test_flow.cpp @@ -1,4 +1,4 @@ -// Copyright 2024 D-Wave Inc. +// Copyright 2024 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/nodes/test_interpolation.cpp b/tests/cpp/nodes/test_interpolation.cpp index 65e81986..3849658c 100644 --- a/tests/cpp/nodes/test_interpolation.cpp +++ b/tests/cpp/nodes/test_interpolation.cpp @@ -1,4 +1,4 @@ -// Copyright 2025 D-Wave Inc. +// Copyright 2025 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/nodes/test_lambda.cpp b/tests/cpp/nodes/test_lambda.cpp index 83641e9c..0ae7a00b 100644 --- a/tests/cpp/nodes/test_lambda.cpp +++ b/tests/cpp/nodes/test_lambda.cpp @@ -1,4 +1,4 @@ -// Copyright 2025 D-Wave Inc. +// Copyright 2025 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 4349961d..575db125 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/nodes/test_quadratic_model.cpp b/tests/cpp/nodes/test_quadratic_model.cpp index 0e348645..b40aac3e 100644 --- a/tests/cpp/nodes/test_quadratic_model.cpp +++ b/tests/cpp/nodes/test_quadratic_model.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/test_array.cpp b/tests/cpp/test_array.cpp index 254fab47..ab51c358 100644 --- a/tests/cpp/test_array.cpp +++ b/tests/cpp/test_array.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/cpp/test_graph.cpp b/tests/cpp/test_graph.cpp index d1975d98..a31d3559 100644 --- a/tests/cpp/test_graph.cpp +++ b/tests/cpp/test_graph.cpp @@ -1,4 +1,4 @@ -// Copyright 2023 D-Wave Systems Inc. +// Copyright 2023 D-Wave // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/tests/test_examples.py b/tests/test_examples.py index f7d19eb1..3803f0e5 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_generators.py b/tests/test_generators.py index b24864ad..05cff43f 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_model.py b/tests/test_model.py index f798781f..b265c678 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_states.py b/tests/test_states.py index 2e551c02..b2e5d210 100644 --- a/tests/test_states.py +++ b/tests/test_states.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 42d029f9..dd71a856 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1,4 +1,4 @@ -# Copyright 2024 D-Wave Systems Inc. +# Copyright 2024 D-Wave # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From 52eea607094e853e7475443993a544a9fe884160 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Wed, 5 Aug 2026 09:33:36 -0700 Subject: [PATCH 16/37] Fix Graph::pop_decision() Handle the case that decisions are not the first N of nodes_ --- dwave/optimization/src/graph.cpp | 33 ++++++++++++++++++++++++-------- tests/cpp/test_graph.cpp | 25 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/dwave/optimization/src/graph.cpp b/dwave/optimization/src/graph.cpp index 07d00b63..6cd22ed4 100644 --- a/dwave/optimization/src/graph.cpp +++ b/dwave/optimization/src/graph.cpp @@ -159,19 +159,36 @@ void Graph::initialize_state(State& state) { void Graph::pop_decision() { assert(not topologically_sorted_ and "cannot pop a decision from a locked model"); + assert(not decisions_.empty() and "need at least one decision"); - // Get the index of the node we're going to delete - const ssize_t i = std::ranges::ssize(decisions_) - 1; - assert(0 <= i and "need at least one decision"); + // Get a pointer to the node we want to remove + const Node* target_ptr = decisions_.back(); - // Check that nodes_ and decisions_ are consistent (should always be true) - assert(nodes_[i].get() == decisions_[i]); + [[maybe_unused]] auto is_target = [&target_ptr](const auto* ptr) { + return static_cast(ptr) == target_ptr; + }; - // Confirm that removing the decision won't leave anything dangling - assert(decisions_[i]->successors().empty() and "cannot remove a decision with successors"); + // Make sure the last decision is unused + assert(target_ptr->successors().empty() and "cannot remove a decision with successors"); + assert(not is_target(objective_ptr_) and "cannot remove the objective"); + assert(std::ranges::none_of(constraints_, is_target) and "cannot remove a constraint"); + // Ok, stop tracking our target in the decisions_ list decisions_.pop_back(); - nodes_.erase(nodes_.begin() + i); + + // Should never have the same decision twice and decisions are not inputs/constants + assert(std::ranges::none_of(decisions_, is_target)); + assert(std::ranges::none_of(inputs_, is_target)); + assert(std::ranges::none_of(constants_, is_target)); + + // Finally, we need to remove it from our node list. We do the swap and pop trick + // because we're not topologically sorted so it's OK for us to mess with the node order + auto it = std::find_if(nodes_.begin(), nodes_.end(), [&target_ptr](const auto& uptr) { + return uptr.get() == target_ptr; + }); + assert(it != nodes_.end()); // our target must be in there somewhere + std::swap(*it, nodes_.back()); + nodes_.pop_back(); } void Graph::propagate(State& state) const { diff --git a/tests/cpp/test_graph.cpp b/tests/cpp/test_graph.cpp index 26c53199..b89c1120 100644 --- a/tests/cpp/test_graph.cpp +++ b/tests/cpp/test_graph.cpp @@ -340,6 +340,31 @@ TEST_CASE("Graph::pop_decision()") { CHECK(y_ptr->topological_index() == 1); CHECK(a_ptr->topological_index() == 2); } + + GIVEN("A graph with an intermediate node added before the last decision") { + auto graph = Graph(); + + auto* x_ptr = graph.emplace_node(); + auto* y_ptr = graph.emplace_node(); + auto* a_ptr = graph.emplace_node(x_ptr, y_ptr); + graph.emplace_node(5); + + graph.pop_decision(); + + CHECK(graph.nodes()[0].get() == x_ptr); + CHECK(graph.nodes()[1].get() == y_ptr); + CHECK(graph.nodes()[2].get() == a_ptr); + + graph.topological_sort(); + + CHECK(graph.nodes()[0].get() == x_ptr); + CHECK(graph.nodes()[1].get() == y_ptr); + CHECK(graph.nodes()[2].get() == a_ptr); + + CHECK(x_ptr->topological_index() == 0); + CHECK(y_ptr->topological_index() == 1); + CHECK(a_ptr->topological_index() == 2); + } } TEST_CASE("Graph::remove_redundant_nodes()") { From e9bad27066d1316309ad59a21d6248513ae41b4e Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Wed, 5 Aug 2026 10:43:50 -0700 Subject: [PATCH 17/37] Fix Graph::swap_decision() Handle the case that decisions are not the first N of nodes_. --- dwave/optimization/src/graph.cpp | 7 ------- tests/cpp/test_graph.cpp | 32 ++++++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/dwave/optimization/src/graph.cpp b/dwave/optimization/src/graph.cpp index 6cd22ed4..86e32d2f 100644 --- a/dwave/optimization/src/graph.cpp +++ b/dwave/optimization/src/graph.cpp @@ -593,14 +593,7 @@ void Graph::swap_decisions(DecisionNode* x_ptr, DecisionNode* y_ptr) { assert(decisions_[x_idx] == x_ptr); assert(decisions_[y_idx] == y_ptr); - assert(0 <= x_idx and static_cast(x_idx) < nodes_.size()); - assert(0 <= y_idx and static_cast(y_idx) < nodes_.size()); - - assert(nodes_[x_idx].get() == static_cast(x_ptr)); - assert(nodes_[y_idx].get() == static_cast(y_ptr)); - using std::swap; // ADL shouldn't matter here, but a good habit nonetheless - swap(nodes_[x_idx], nodes_[y_idx]); swap(decisions_[x_idx], decisions_[y_idx]); swap(x_idx, y_idx); } diff --git a/tests/cpp/test_graph.cpp b/tests/cpp/test_graph.cpp index b89c1120..48c89036 100644 --- a/tests/cpp/test_graph.cpp +++ b/tests/cpp/test_graph.cpp @@ -560,10 +560,6 @@ TEST_CASE("Graph::swap_decisions") { CHECK_THAT(graph.decisions(), RangeEquals({x_ptr, y_ptr, z_ptr})); - CHECK(graph.nodes()[0].get() == x_ptr); - CHECK(graph.nodes()[1].get() == y_ptr); - CHECK(graph.nodes()[2].get() == z_ptr); - CHECK(x_ptr->topological_index() == 0); CHECK(y_ptr->topological_index() == 1); CHECK(z_ptr->topological_index() == 2); @@ -572,13 +568,41 @@ TEST_CASE("Graph::swap_decisions") { CHECK_THAT(graph.decisions(), RangeEquals({x_ptr, z_ptr, y_ptr})); + CHECK(x_ptr->topological_index() == 0); + CHECK(y_ptr->topological_index() == 2); + CHECK(z_ptr->topological_index() == 1); + + graph.topological_sort(); + CHECK(graph.nodes()[0].get() == x_ptr); CHECK(graph.nodes()[1].get() == z_ptr); CHECK(graph.nodes()[2].get() == y_ptr); + } + + GIVEN("A graph with an intermediate node added before the last decision") { + auto graph = Graph(); + + auto* x_ptr = graph.emplace_node(); + auto* y_ptr = graph.emplace_node(); + auto* a_ptr = graph.emplace_node(x_ptr, y_ptr); + auto* z_ptr = graph.emplace_node(5); + + CHECK_THAT(graph.decisions(), RangeEquals(std::vector{x_ptr, y_ptr, z_ptr})); + + graph.swap_decisions(y_ptr, z_ptr); + + CHECK_THAT(graph.decisions(), RangeEquals(std::vector{x_ptr, z_ptr, y_ptr})); CHECK(x_ptr->topological_index() == 0); CHECK(y_ptr->topological_index() == 2); CHECK(z_ptr->topological_index() == 1); + + graph.topological_sort(); + + CHECK(graph.nodes()[0].get() == x_ptr); + CHECK(graph.nodes()[1].get() == z_ptr); + CHECK(graph.nodes()[2].get() == y_ptr); + CHECK(graph.nodes()[3].get() == a_ptr); } } From f54877ddef376ea6626150b9b6900efa26296a08 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Thu, 6 Aug 2026 11:32:15 -0700 Subject: [PATCH 18/37] Address nonfunctional comments from code review --- .../include/dwave-optimization/nodes/testing.hpp | 2 +- dwave/optimization/src/nodes/_checkpoints.cpp | 16 +++++++--------- dwave/optimization/src/nodes/_state.hpp | 2 +- dwave/optimization/src/nodes/collections.cpp | 3 +-- dwave/optimization/src/nodes/numbers.cpp | 4 ++++ tests/cpp/nodes/test_collections.cpp | 4 +--- tests/cpp/nodes/test_numbers.cpp | 4 ++-- 7 files changed, 17 insertions(+), 18 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp index 16179ead..01f88406 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp @@ -102,7 +102,7 @@ class DynamicArrayTestingNode : public ArrayOutputMixin, public Decis // Overloads required by the DecisionNode ABC ***************************** - // DynamicArrayTestingNode does not impement checkpointing + // DynamicArrayTestingNode does not implement checkpointing [[noreturn]] void assign_from_checkpoint( State& state, checkpoint_type& checkpoint diff --git a/dwave/optimization/src/nodes/_checkpoints.cpp b/dwave/optimization/src/nodes/_checkpoints.cpp index 4b3fe59b..c45bbfe8 100644 --- a/dwave/optimization/src/nodes/_checkpoints.cpp +++ b/dwave/optimization/src/nodes/_checkpoints.cpp @@ -53,15 +53,13 @@ DiffCheckpoint::DiffCheckpoint(CheckpointableState& state, std::span(prev_ptr_); - assert(prev_ptr->drop_ == 0); - for (auto& updates : updates_) prev_ptr->commit_updates(std::move(updates)); - prev_ptr->drop_ = drop_; + // If we're not the oldest checkpoint, we need to transfer our information + // over so it's not lost + if (auto* prev_ptr = static_cast(prev_ptr_)) { + assert(prev_ptr->drop_ == 0); + for (auto& updates : updates_) prev_ptr->commit_updates(std::move(updates)); + prev_ptr->drop_ = drop_; + } } void DiffCheckpoint::commit_updates(std::vector updates) { diff --git a/dwave/optimization/src/nodes/_state.hpp b/dwave/optimization/src/nodes/_state.hpp index 5a2da6cb..43986b78 100644 --- a/dwave/optimization/src/nodes/_state.hpp +++ b/dwave/optimization/src/nodes/_state.hpp @@ -165,7 +165,7 @@ class ArrayStateData { size_ = buffer.size(); } - // Commit the changes and clear the diff by returning the diff buffer. + // Revert the changes and clear the diff by returning the diff buffer. std::vector revert_and_detach() { assert(previous_size_ >= 0); buffer.resize(previous_size_); diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 1bb52235..c117118c 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -585,8 +585,7 @@ void DisjointBitSetsNode::assign_from_checkpoint( State& state, std::unique_ptr& checkpoint ) const { - const DisjointBitSetsCheckpoint_* checkpoint_ptr = - static_cast(checkpoint.get()); + const auto* checkpoint_ptr = static_cast(checkpoint.get()); data_ptr_(state)->assign(checkpoint_ptr->buffer); } diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index e0b10673..bd6ad8bd 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -238,6 +238,10 @@ void NumberNodeStateData::revert() { slice_cache_.clear(); // Empty the slice cache. ArrayNodeStateData::revert(); // Revert changes to the buffer. } + + // everything should have been cleared out regardless of which path we took + assert(this->diff().empty()); + assert(slice_cache_.empty()); } void NumberNodeStateData::update( diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index adceabdb..597ab0eb 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -889,7 +889,7 @@ TEST_CASE("SetNode") { set_ptr->assign_from_checkpoint(state, std::move(checkpoint0)); - // CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); + CHECK_THAT(set_ptr->view(state), RangeEquals({0, 1})); } } @@ -950,8 +950,6 @@ TEST_CASE("SetNode") { } } - // TODO: within one propagation - WHEN("We do several mutations and create several checkpoints within the same commit") { auto checkpoint0 = set_ptr->checkpoint(state); diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 45d228b0..7c55d558 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -299,8 +299,8 @@ TEST_CASE("BinaryNode") { AND_WHEN("We create a checkpoint to that state") { auto checkpoint = ptr->checkpoint(state); // 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 - THEN("We can mutate and then assign from that checkpoint") { - ptr->set_value(state, 0, 1); // 0, 1, 0, 1, 0, 1, 0, 1, 0, 1 + THEN("We can mutate, then propose, and then assign from that checkpoint") { + ptr->set_value(state, 0, 1); // 1, 1, 0, 1, 0, 1, 0, 1, 0, 1 graph.propose(state); ptr->assign_from_checkpoint(state, checkpoint); From 7343b417207d4cc04879e5401fd3bee4efc3ae0b Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Thu, 6 Aug 2026 14:57:59 -0700 Subject: [PATCH 19/37] Address functional comments from code review --- dwave/optimization/src/nodes/_checkpoints.hpp | 4 ++++ dwave/optimization/src/nodes/numbers.cpp | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dwave/optimization/src/nodes/_checkpoints.hpp b/dwave/optimization/src/nodes/_checkpoints.hpp index f6187ca0..af39e978 100644 --- a/dwave/optimization/src/nodes/_checkpoints.hpp +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -71,6 +71,10 @@ class CheckpointableState { T* checkpoint_ptr() { return static_cast(prev_ptr_); } + template T> + const T* checkpoint_ptr() const { + return static_cast(prev_ptr_); + } private: friend LinkedListCheckpoint; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index bd6ad8bd..c547d51f 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -180,6 +180,10 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat assert(slice_cache_.empty()); } + const NumberNodeCheckpoint_* last_checkpoint() const { + return checkpoint_ptr(); + } + /// Revert the state dependent data of NumberNode. void revert(); @@ -1058,7 +1062,7 @@ void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoi auto* checkpoint_ptr = static_cast(checkpoint.get()); - // todo: assert that this checkpoint is the latest + assert(checkpoint_ptr == state_data->last_checkpoint()); // Check if there are any changes not otherwise tracked by a checkpoint that we need // to revert first. @@ -1067,11 +1071,14 @@ void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoi // tested. if (ssize_t excess_updates = state_data->diff().size() - checkpoint_ptr->drop()) { assert(excess_updates > 0); + for ( const auto& [idx, old, _] : state_data->diff() | std::views::reverse | std::views::take(excess_updates) ) { - state_data->set(idx, old); + // This is a *very* expensive call. But, again, we're not too worried about + // performance here. + this->set_value(state, idx, old); } } From 885c8f1b774b164df65dc63d191f42b78e76108f Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 7 Aug 2026 14:29:56 -0700 Subject: [PATCH 20/37] Update NumberNodeStateData to hold a reference to NumberNode --- dwave/optimization/src/nodes/numbers.cpp | 99 +++++++++++++----------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index a5c7e1e7..42731c61 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -148,13 +148,19 @@ class NumberNodeCheckpoint_ : public DiffCheckpoint { class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableState { public: // User does not provide sum constraints. - NumberNodeStateData(std::vector input) : ArrayNodeStateData(std::move(input)) {} + NumberNodeStateData(const NumberNode& node, std::vector input) : + ArrayNodeStateData(std::move(input)), sum_constraints_lhs(), slice_cache_(), node_(node) {} + // User provides sum constraints. NumberNodeStateData( + const NumberNode& node, std::vector input, std::vector> sum_constraints_lhs ) : - ArrayNodeStateData(std::move(input)), sum_constraints_lhs(std::move(sum_constraints_lhs)) {} + ArrayNodeStateData(std::move(input)), + sum_constraints_lhs(std::move(sum_constraints_lhs)), + slice_cache_(), + node_(node) {} std::unique_ptr checkpoint() { return std::make_unique(*this, this->diff(), this->slice_cache_); @@ -189,10 +195,10 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat /// Update the relevant sum constraints running sums (`lhs`) given that the /// value stored at `index` is changed by `difference`. - void update(const NumberNode& node, const ssize_t index, const double difference); + void update(const ssize_t index, const double difference); + /// Users may pass the slices (per sum constraint) that `index` lies on. void update( - const NumberNode& node, const ssize_t index, const double difference, std::vector slices @@ -213,6 +219,10 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat /// slice_cache_[i][j] = The slice of the `j`th sum constraint that the index /// of the `i`th update lies on. std::vector> slice_cache_; + + /// Hold a reference to the parent node. This class can outlive the node but + /// cannot be accessed except through it. + const NumberNode& node_; }; void NumberNodeStateData::revert() { @@ -249,11 +259,10 @@ void NumberNodeStateData::revert() { } void NumberNodeStateData::update( - const NumberNode& node, const ssize_t index, const double difference ) { - const auto& sum_constraints = node.sum_constraints(); + const auto& sum_constraints = node_.sum_constraints(); assert(sum_constraints.size() != 0); // Should only call where applicable. assert(difference != 0); // Should not call when no change occurs. assert(sum_constraints.size() == sum_constraints_lhs.size()); @@ -262,7 +271,7 @@ void NumberNodeStateData::update( cache_entry.reserve(sum_constraints.size()); // Get multidimensional indices for `index` so we can identify the slices // `index` lies on per sum constraint. - const std::vector multi_index = unravel_index(index, node.shape()); + const std::vector multi_index = unravel_index(index, node_.shape()); assert(sum_constraints.size() <= multi_index.size()); // For each sum constraint. for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { @@ -280,12 +289,11 @@ void NumberNodeStateData::update( } void NumberNodeStateData::update( - const NumberNode& node, const ssize_t index, const double difference, std::vector slices ) { - const auto& sum_constraints = node.sum_constraints(); + const auto& sum_constraints = node_.sum_constraints(); assert(sum_constraints.size() != 0); // Should only call where applicable. assert(difference != 0); // Should not call when no change occurs. assert(sum_constraints.size() == sum_constraints_lhs.size()); @@ -299,7 +307,7 @@ void NumberNodeStateData::update( /// If `axis == std::nullopt`, the array is treated as a flat array with a /// single slice. Otherwise, the slice is defined by unravel_index(). if (!axis.has_value()) return slices[i] == 0; - return slices[i] == unravel_index(index, node.shape())[*axis]; + return slices[i] == unravel_index(index, node_.shape())[*axis]; })()); sum_constraints_lhs[i][slices[i]] += difference; // Offset slice sum. } @@ -420,7 +428,7 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } if (sum_constraints_.size() == 0) { // No sum constraints to consider. - emplace_data_ptr_(state, std::move(number_data)); + emplace_data_ptr_(state, *this, std::move(number_data)); } else { // Given the assignment to NumberNode `number_data`, compute the sum // of the values within each slice per sum constraint. @@ -431,7 +439,7 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } emplace_data_ptr_( - state, std::move(number_data), std::move(sum_constraints_lhs) + state, *this, std::move(number_data), std::move(sum_constraints_lhs) ); } } @@ -664,15 +672,15 @@ void NumberNode::exchange( if (i_slices.has_value()) { assert(j_slices.has_value()); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(*this, i, difference, *i_slices); + state_data->update(i, difference, *i_slices); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(*this, j, -difference, *j_slices); + state_data->update(j, -difference, *j_slices); } else { assert(!j_slices.has_value()); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(*this, i, difference); + state_data->update(i, difference); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(*this, j, -difference); + state_data->update(j, -difference); } } } @@ -732,9 +740,9 @@ void NumberNode::clip_and_set_value( // If change occurred and sum constraint exist, update running sums. if (sum_constraints_.size() > 0) { if (slices.has_value()) { - state_data->update(*this, index, value - diff(state).back().old, *slices); + state_data->update(index, value - diff(state).back().old, *slices); } else { - state_data->update(*this, index, value - diff(state).back().old); + state_data->update(index, value - diff(state).back().old); } } } @@ -1091,7 +1099,7 @@ void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoi for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { state_data->set(idx, old); - state_data->update(*this, idx, old - diff(state).back().old, *(slices_rit++)); + state_data->update(idx, old - diff(state).back().old, *(slices_rit++)); } } else { assert(updates.empty() or sum_constraints_.empty()); @@ -1134,9 +1142,9 @@ void IntegerNode::set_value( // If change occurred and sum constraint exist, update running sums. if (sum_constraints_.size() > 0) { if (slices.has_value()) { - state_data->update(*this, index, value - diff(state).back().old, *slices); + state_data->update(index, value - diff(state).back().old, *slices); } else { - state_data->update(*this, index, value - diff(state).back().old); + state_data->update(index, value - diff(state).back().old); } } } @@ -1417,14 +1425,16 @@ struct BinaryNodeStateData : public NumberNodeStateData { }; // User does not provide sum constraints. - BinaryNodeStateData(std::vector input) : NumberNodeStateData(std::move(input)) {} + BinaryNodeStateData(const BinaryNode& node, std::vector input) : + NumberNodeStateData(node, std::move(input)) {} + // User provides sum constraints. BinaryNodeStateData( + const BinaryNode& node, std::vector input, - std::vector> sum_constraints_lhs, - const BinaryNode& node + std::vector> sum_constraints_lhs ) : - NumberNodeStateData(std::move(input), std::move(sum_constraints_lhs)) { + NumberNodeStateData(node, std::move(input), std::move(sum_constraints_lhs)) { compute_slice_indices_(node); } @@ -1437,10 +1447,10 @@ struct BinaryNodeStateData : public NumberNodeStateData { /// Update `sum_constraints_lhs` and `slice_indices` given that the value /// stored at `index` is changed by `difference`. - void update(const BinaryNode& node, const ssize_t index, const double difference); + void update(const ssize_t index, const double difference); + /// Users may pass the slices (per sum constraint) that `index` lies on. void update( - const BinaryNode& node, const ssize_t index, const double difference, std::vector slices @@ -1487,11 +1497,10 @@ void BinaryNodeStateData::revert() { } void BinaryNodeStateData::update( - const BinaryNode& node, const ssize_t index, const double difference ) { - const auto& sum_constraints = node.sum_constraints(); + const auto& sum_constraints = node_.sum_constraints(); assert(sum_constraints.size() != 0); // Should only call where applicable. assert(difference == 1 || difference == -1); assert(sum_constraints.size() == sum_constraints_lhs.size()); @@ -1500,7 +1509,7 @@ void BinaryNodeStateData::update( cache_entry.reserve(sum_constraints.size()); // Get multidimensional indices for `index` so we can identify the slices // `index` lies on per sum constraint. - const std::vector multi_index = unravel_index(index, node.shape()); + const std::vector multi_index = unravel_index(index, node_.shape()); assert(sum_constraints.size() <= multi_index.size()); // For each sum constraint. for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { @@ -1524,12 +1533,11 @@ void BinaryNodeStateData::update( } void BinaryNodeStateData::update( - const BinaryNode& node, const ssize_t index, const double difference, std::vector slices ) { - const auto& sum_constraints = node.sum_constraints(); + const auto& sum_constraints = node_.sum_constraints(); assert(sum_constraints.size() != 0); // Should only call where applicable. assert(difference == 1 || difference == -1); assert(sum_constraints.size() == sum_constraints_lhs.size()); @@ -1544,7 +1552,7 @@ void BinaryNodeStateData::update( /// If `axis == std::nullopt`, the array is treated as a flat array with a /// single slice. Otherwise, the slice is defined by multi_index. if (!axis.has_value()) return slices[i] == 0; - return slices[i] == unravel_index(index, node.shape())[*axis]; + return slices[i] == unravel_index(index, node_.shape())[*axis]; })()); sum_constraints_lhs[i][slices[i]] += difference; // Offset slice sum. // Update tracked indices. @@ -1608,7 +1616,7 @@ void BinaryNode::initialize_state(State& state, std::vector&& number_dat } if (sum_constraints_.size() == 0) { // No sum constraints to consider. - emplace_data_ptr_(state, std::move(number_data)); + emplace_data_ptr_(state, *this, std::move(number_data)); } else { // Given the assignment to NumberNode `number_data`, compute the sum of // the values within each slice per sum constraint. @@ -1619,8 +1627,7 @@ void BinaryNode::initialize_state(State& state, std::vector&& number_dat } emplace_data_ptr_( - state, std::move(number_data), std::move(sum_constraints_lhs), *this - ); + state, *this, std::move(number_data), std::move(sum_constraints_lhs)); } } @@ -1667,15 +1674,15 @@ void BinaryNode::exchange( if (i_slices.has_value()) { assert(j_slices.has_value()); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(*this, i, difference, *i_slices); + state_data->update(i, difference, *i_slices); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(*this, j, -difference, *j_slices); + state_data->update(j, -difference, *j_slices); } else { assert(!j_slices.has_value()); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(*this, i, difference); + state_data->update(i, difference); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(*this, j, -difference); + state_data->update(j, -difference); } } } @@ -1695,9 +1702,9 @@ void BinaryNode::clip_and_set_value( // If change occurred and sum constraint exist, update running sums. if (sum_constraints_.size() > 0) { if (slices.has_value()) { - state_data->update(*this, index, value - diff(state).back().old, *slices); + state_data->update(index, value - diff(state).back().old, *slices); } else { - state_data->update(*this, index, value - diff(state).back().old); + state_data->update(index, value - diff(state).back().old); } } } @@ -1720,9 +1727,9 @@ void BinaryNode::set_value( // If change occurred and sum constraint exist, update running sums. if (sum_constraints_.size() > 0) { if (slices.has_value()) { - state_data->update(*this, index, value - diff(state).back().old, *slices); + state_data->update(index, value - diff(state).back().old, *slices); } else { - state_data->update(*this, index, value - diff(state).back().old); + state_data->update(index, value - diff(state).back().old); } } } @@ -1744,9 +1751,9 @@ void BinaryNode::flip( // If value changed from 0 -> 1, update by 1. // If value changed from 1 -> 0, update by -1. if (slices.has_value()) { - state_data->update(*this, index, (state_data->get(index) == 1) ? 1 : -1, *slices); + state_data->update(index, (state_data->get(index) == 1) ? 1 : -1, *slices); } else { - state_data->update(*this, index, (state_data->get(index) == 1) ? 1 : -1); + state_data->update(index, (state_data->get(index) == 1) ? 1 : -1); } } } From b0f6c0711d843b646a1ef20b5c1a0fe7b05bfd27 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 7 Aug 2026 14:47:40 -0700 Subject: [PATCH 21/37] Make a single (virtual) overload for NumberNodeStateData::update() --- dwave/optimization/src/nodes/numbers.cpp | 199 +++++++++++++---------- 1 file changed, 112 insertions(+), 87 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 42731c61..baad8f53 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -195,13 +195,11 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat /// Update the relevant sum constraints running sums (`lhs`) given that the /// value stored at `index` is changed by `difference`. - void update(const ssize_t index, const double difference); - /// Users may pass the slices (per sum constraint) that `index` lies on. - void update( - const ssize_t index, - const double difference, - std::vector slices + virtual void update( + ssize_t index, + double difference, + std::optional> optional_slices = std::nullopt ); /// For each sum constraint, track the sum of the values within each slice. @@ -258,60 +256,58 @@ void NumberNodeStateData::revert() { assert(slice_cache_.empty()); } -void NumberNodeStateData::update( - const ssize_t index, - const double difference -) { - const auto& sum_constraints = node_.sum_constraints(); - assert(sum_constraints.size() != 0); // Should only call where applicable. - assert(difference != 0); // Should not call when no change occurs. - assert(sum_constraints.size() == sum_constraints_lhs.size()); - - std::vector cache_entry; // Initialize the slice cache. - cache_entry.reserve(sum_constraints.size()); - // Get multidimensional indices for `index` so we can identify the slices - // `index` lies on per sum constraint. - const std::vector multi_index = unravel_index(index, node_.shape()); - assert(sum_constraints.size() <= multi_index.size()); - // For each sum constraint. - for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { - const std::optional axis = sum_constraints[i].axis(); - /// Determine the "slice" that index lies on given the sum constraint. - /// If `axis == std::nullopt`, the array is treated as a flat array with a - /// single slice. Otherwise, the slice is defined by multi_index. - assert(!axis.has_value() || *axis < static_cast(multi_index.size())); - const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; - assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); - sum_constraints_lhs[i][slice] += difference; // Offset slice sum. - cache_entry.push_back(slice); // Record the slice in the cache. - } - slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. -} - void NumberNodeStateData::update( const ssize_t index, const double difference, - std::vector slices + std::optional> optional_slices ) { const auto& sum_constraints = node_.sum_constraints(); assert(sum_constraints.size() != 0); // Should only call where applicable. assert(difference != 0); // Should not call when no change occurs. assert(sum_constraints.size() == sum_constraints_lhs.size()); - assert(sum_constraints.size() == slices.size()); - // For each sum constraint. - for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { - // Sanity check that the user provided slices for `index` are correct. - assert(([&]() { + + // Dev note: there is a tonne of deduplication one could do here. Keeping this + // as-is to minimize changes in the current PR. This needs another pass in the + // future. + if (optional_slices) { + std::vector slices = std::move(*optional_slices); + + assert(sum_constraints.size() == slices.size()); + // For each sum constraint. + for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { + // Sanity check that the user provided slices for `index` are correct. + assert(([&]() { + const std::optional axis = sum_constraints[i].axis(); + /// Determine the "slice" that index lies on given the sum constraint. + /// If `axis == std::nullopt`, the array is treated as a flat array with a + /// single slice. Otherwise, the slice is defined by unravel_index(). + if (!axis.has_value()) return slices[i] == 0; + return slices[i] == unravel_index(index, node_.shape())[*axis]; + })()); + sum_constraints_lhs[i][slices[i]] += difference; // Offset slice sum. + } + slice_cache_.emplace_back(std::move(slices)); // Cache the slices. + } else { + std::vector cache_entry; // Initialize the slice cache. + cache_entry.reserve(sum_constraints.size()); + // Get multidimensional indices for `index` so we can identify the slices + // `index` lies on per sum constraint. + const std::vector multi_index = unravel_index(index, node_.shape()); + assert(sum_constraints.size() <= multi_index.size()); + // For each sum constraint. + for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { const std::optional axis = sum_constraints[i].axis(); /// Determine the "slice" that index lies on given the sum constraint. /// If `axis == std::nullopt`, the array is treated as a flat array with a - /// single slice. Otherwise, the slice is defined by unravel_index(). - if (!axis.has_value()) return slices[i] == 0; - return slices[i] == unravel_index(index, node_.shape())[*axis]; - })()); - sum_constraints_lhs[i][slices[i]] += difference; // Offset slice sum. + /// single slice. Otherwise, the slice is defined by multi_index. + assert(!axis.has_value() || *axis < static_cast(multi_index.size())); + const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; + assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); + sum_constraints_lhs[i][slice] += difference; // Offset slice sum. + cache_entry.push_back(slice); // Record the slice in the cache. + } + slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. } - slice_cache_.emplace_back(std::move(slices)); // Cache the slices. } double const* NumberNode::buff(const State& state) const noexcept { @@ -1447,14 +1443,12 @@ struct BinaryNodeStateData : public NumberNodeStateData { /// Update `sum_constraints_lhs` and `slice_indices` given that the value /// stored at `index` is changed by `difference`. - void update(const ssize_t index, const double difference); - /// Users may pass the slices (per sum constraint) that `index` lies on. void update( - const ssize_t index, - const double difference, - std::vector slices - ); + ssize_t index, + double difference, + std::optional> optional_slices = std::nullopt + ) override; /// A collection of DisjointSparseSet, one per sum constraint. std::vector slice_indices; @@ -1496,15 +1490,78 @@ void BinaryNodeStateData::revert() { ArrayNodeStateData::revert(); // Revert changes to the buffer. } +// void BinaryNodeStateData::update( +// const ssize_t index, +// const double difference +// ) { +// const auto& sum_constraints = node_.sum_constraints(); +// assert(sum_constraints.size() != 0); // Should only call where applicable. +// assert(difference == 1 || difference == -1); +// assert(sum_constraints.size() == sum_constraints_lhs.size()); +// assert(sum_constraints.size() == slice_indices.size()); +// std::vector cache_entry; // Initialize the slice cache. +// cache_entry.reserve(sum_constraints.size()); +// // Get multidimensional indices for `index` so we can identify the slices +// // `index` lies on per sum constraint. +// const std::vector multi_index = unravel_index(index, node_.shape()); +// assert(sum_constraints.size() <= multi_index.size()); +// // For each sum constraint. +// for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { +// const std::optional axis = sum_constraints[i].axis(); +// /// Determine the "slice" that index lies on given the sum constraint. +// /// If `axis == std::nullopt`, the array is treated as a flat array with a +// /// single slice. Otherwise, the slice is defined by multi_index. +// assert(!axis.has_value() || *axis < static_cast(multi_index.size())); +// const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; +// assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); +// sum_constraints_lhs[i][slice] += difference; // Offset slice sum. +// // Update tracked indices. +// if (difference == 1.0) { +// slice_indices[i].update_true(index, slice); +// } else { +// slice_indices[i].update_false(index, slice); +// } +// cache_entry.push_back(slice); // Record the slice in the cache. +// } +// slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. +// } + void BinaryNodeStateData::update( const ssize_t index, - const double difference + const double difference, + std::optional> optional_slices ) { const auto& sum_constraints = node_.sum_constraints(); assert(sum_constraints.size() != 0); // Should only call where applicable. assert(difference == 1 || difference == -1); assert(sum_constraints.size() == sum_constraints_lhs.size()); assert(sum_constraints.size() == slice_indices.size()); + + if (optional_slices) { + std::vector slices = std::move(*optional_slices); + + assert(sum_constraints.size() == slices.size()); + // For each sum constraint. + for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { + // Sanity check that the user provided slices for `index` are correct. + assert(([&]() { + const std::optional axis = sum_constraints[i].axis(); + /// Determine the "slice" that index lies on given the sum constraint. + /// If `axis == std::nullopt`, the array is treated as a flat array with a + /// single slice. Otherwise, the slice is defined by multi_index. + if (!axis.has_value()) return slices[i] == 0; + return slices[i] == unravel_index(index, node_.shape())[*axis]; + })()); + sum_constraints_lhs[i][slices[i]] += difference; // Offset slice sum. + // Update tracked indices. + if (difference == 1.0) { + slice_indices[i].update_true(index, slices[i]); + } else { + slice_indices[i].update_false(index, slices[i]); + } + } + slice_cache_.emplace_back(std::move(slices)); // Cache the slices. + } else { std::vector cache_entry; // Initialize the slice cache. cache_entry.reserve(sum_constraints.size()); // Get multidimensional indices for `index` so we can identify the slices @@ -1530,39 +1587,7 @@ void BinaryNodeStateData::update( cache_entry.push_back(slice); // Record the slice in the cache. } slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. -} - -void BinaryNodeStateData::update( - const ssize_t index, - const double difference, - std::vector slices -) { - const auto& sum_constraints = node_.sum_constraints(); - assert(sum_constraints.size() != 0); // Should only call where applicable. - assert(difference == 1 || difference == -1); - assert(sum_constraints.size() == sum_constraints_lhs.size()); - assert(sum_constraints.size() == slice_indices.size()); - assert(sum_constraints.size() == slices.size()); - // For each sum constraint. - for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { - // Sanity check that the user provided slices for `index` are correct. - assert(([&]() { - const std::optional axis = sum_constraints[i].axis(); - /// Determine the "slice" that index lies on given the sum constraint. - /// If `axis == std::nullopt`, the array is treated as a flat array with a - /// single slice. Otherwise, the slice is defined by multi_index. - if (!axis.has_value()) return slices[i] == 0; - return slices[i] == unravel_index(index, node_.shape())[*axis]; - })()); - sum_constraints_lhs[i][slices[i]] += difference; // Offset slice sum. - // Update tracked indices. - if (difference == 1.0) { - slice_indices[i].update_true(index, slices[i]); - } else { - slice_indices[i].update_false(index, slices[i]); - } } - slice_cache_.emplace_back(std::move(slices)); // Cache the slices. } void BinaryNodeStateData::compute_slice_indices_(const BinaryNode& node) { From aef592b5aa0e62942c1d14f6eeb7b9f96e372a57 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 7 Aug 2026 15:10:51 -0700 Subject: [PATCH 22/37] Update NumberNode::exhange() to handle subclasses and remove BinaryNode::exhange() --- .../dwave-optimization/nodes/numbers.hpp | 9 -- dwave/optimization/src/nodes/numbers.cpp | 103 +++++++----------- 2 files changed, 37 insertions(+), 75 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index b0002ca0..d342961a 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -415,15 +415,6 @@ class BinaryNode : public IntegerNode { return initialize_state(state, std::vector(values.begin(), values.end())); } - /// @copydoc NumberNode::exchange() - void exchange( - State& state, - ssize_t i, - ssize_t j, - std::optional> i_slices = std::nullopt, - std::optional> j_slices = std::nullopt - ) const; - /// @copydoc NumberNode::clip_and_set_value() void clip_and_set_value( State& state, diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index baad8f53..8d3f407a 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -186,6 +186,42 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat assert(slice_cache_.empty()); } + void exchange( + ssize_t i, + ssize_t j, + std::optional> i_slices, + std::optional> j_slices + ) { + // We expect the exchange to obey the index-wise bounds. + assert(node_.lower_bound(i) <= get(j)); + assert(node_.upper_bound(i) >= get(j)); + assert(node_.lower_bound(j) <= get(i)); + assert(node_.upper_bound(j) >= get(i)); + + // assert() that i and j are valid indices occurs in ptr->exchange(). + // State change occurs IFF (i != j) and (buffer[i] != buffer[j]). + if (ArrayNodeStateData::exchange(i, j)) { + // If change occurred and sum constraint exist, update running sums. + if (node_.sum_constraints().size() > 0) { + const double difference = get(i) - get(j); + + if (i_slices.has_value()) { + assert(j_slices.has_value()); + // Index i changed from (what is now) ptr->get(j) to ptr->get(i) + update(i, difference, *i_slices); + // Index j changed from (what is now) ptr->get(i) to ptr->get(j) + update(j, -difference, *j_slices); + } else { + assert(!j_slices.has_value()); + // Index i changed from (what is now) ptr->get(j) to ptr->get(i) + update(i, difference); + // Index j changed from (what is now) ptr->get(i) to ptr->get(j) + update(j, -difference); + } + } + } + } + const NumberNodeCheckpoint_* last_checkpoint() const { return checkpoint_ptr(); } @@ -652,34 +688,7 @@ void NumberNode::exchange( std::optional> i_slices, std::optional> j_slices ) const { - auto state_data = data_ptr_(state); - // We expect the exchange to obey the index-wise bounds. - assert(lower_bound(i) <= state_data->get(j)); - assert(upper_bound(i) >= state_data->get(j)); - assert(lower_bound(j) <= state_data->get(i)); - assert(upper_bound(j) >= state_data->get(i)); - // assert() that i and j are valid indices occurs in ptr->exchange(). - // State change occurs IFF (i != j) and (buffer[i] != buffer[j]). - if (state_data->exchange(i, j)) { - // If change occurred and sum constraint exist, update running sums. - if (sum_constraints_.size() > 0) { - const double difference = state_data->get(i) - state_data->get(j); - - if (i_slices.has_value()) { - assert(j_slices.has_value()); - // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(i, difference, *i_slices); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(j, -difference, *j_slices); - } else { - assert(!j_slices.has_value()); - // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(i, difference); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(j, -difference); - } - } - } + data_ptr_(state)->exchange(i, j, std::move(i_slices), std::move(j_slices)); } double NumberNode::get_value(const State& state, ssize_t i) const { @@ -1675,44 +1684,6 @@ void BinaryNode::initialize_state(State& state) const { } } -void BinaryNode::exchange( - State& state, - ssize_t i, - ssize_t j, - std::optional> i_slices, - std::optional> j_slices -) const { - auto state_data = data_ptr_(state); - // We expect the exchange to obey the index-wise bounds. - assert(lower_bound(i) <= state_data->get(j)); - assert(upper_bound(i) >= state_data->get(j)); - assert(lower_bound(j) <= state_data->get(i)); - assert(upper_bound(j) >= state_data->get(i)); - // assert() that i and j are valid indices occurs in ptr->exchange(). State - // change occurs IFF (i != j) and (buffer[i] != buffer[j]). - if (state_data->exchange(i, j)) { - // If change occurred and sum constraint exist, update - // running sums. - if (sum_constraints_.size() > 0) { - const double difference = state_data->get(i) - state_data->get(j); - - if (i_slices.has_value()) { - assert(j_slices.has_value()); - // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(i, difference, *i_slices); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(j, -difference, *j_slices); - } else { - assert(!j_slices.has_value()); - // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - state_data->update(i, difference); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(j, -difference); - } - } - } -} - void BinaryNode::clip_and_set_value( State& state, ssize_t index, From fe7046b4874562f2a7a52b3a8af773c9a16accd4 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 7 Aug 2026 15:15:33 -0700 Subject: [PATCH 23/37] Add comment to BinaryNodeStateData::update() and remove dead code --- dwave/optimization/src/nodes/numbers.cpp | 87 ++++++++---------------- 1 file changed, 27 insertions(+), 60 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 8d3f407a..af7c3984 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -1499,42 +1499,6 @@ void BinaryNodeStateData::revert() { ArrayNodeStateData::revert(); // Revert changes to the buffer. } -// void BinaryNodeStateData::update( -// const ssize_t index, -// const double difference -// ) { -// const auto& sum_constraints = node_.sum_constraints(); -// assert(sum_constraints.size() != 0); // Should only call where applicable. -// assert(difference == 1 || difference == -1); -// assert(sum_constraints.size() == sum_constraints_lhs.size()); -// assert(sum_constraints.size() == slice_indices.size()); -// std::vector cache_entry; // Initialize the slice cache. -// cache_entry.reserve(sum_constraints.size()); -// // Get multidimensional indices for `index` so we can identify the slices -// // `index` lies on per sum constraint. -// const std::vector multi_index = unravel_index(index, node_.shape()); -// assert(sum_constraints.size() <= multi_index.size()); -// // For each sum constraint. -// for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { -// const std::optional axis = sum_constraints[i].axis(); -// /// Determine the "slice" that index lies on given the sum constraint. -// /// If `axis == std::nullopt`, the array is treated as a flat array with a -// /// single slice. Otherwise, the slice is defined by multi_index. -// assert(!axis.has_value() || *axis < static_cast(multi_index.size())); -// const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; -// assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); -// sum_constraints_lhs[i][slice] += difference; // Offset slice sum. -// // Update tracked indices. -// if (difference == 1.0) { -// slice_indices[i].update_true(index, slice); -// } else { -// slice_indices[i].update_false(index, slice); -// } -// cache_entry.push_back(slice); // Record the slice in the cache. -// } -// slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. -// } - void BinaryNodeStateData::update( const ssize_t index, const double difference, @@ -1546,6 +1510,9 @@ void BinaryNodeStateData::update( assert(sum_constraints.size() == sum_constraints_lhs.size()); assert(sum_constraints.size() == slice_indices.size()); + // Dev note: there is a tonne of deduplication one could do here. Keeping this + // as-is to minimize changes in the current PR. This needs another pass in the + // future. if (optional_slices) { std::vector slices = std::move(*optional_slices); @@ -1571,31 +1538,31 @@ void BinaryNodeStateData::update( } slice_cache_.emplace_back(std::move(slices)); // Cache the slices. } else { - std::vector cache_entry; // Initialize the slice cache. - cache_entry.reserve(sum_constraints.size()); - // Get multidimensional indices for `index` so we can identify the slices - // `index` lies on per sum constraint. - const std::vector multi_index = unravel_index(index, node_.shape()); - assert(sum_constraints.size() <= multi_index.size()); - // For each sum constraint. - for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { - const std::optional axis = sum_constraints[i].axis(); - /// Determine the "slice" that index lies on given the sum constraint. - /// If `axis == std::nullopt`, the array is treated as a flat array with a - /// single slice. Otherwise, the slice is defined by multi_index. - assert(!axis.has_value() || *axis < static_cast(multi_index.size())); - const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; - assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); - sum_constraints_lhs[i][slice] += difference; // Offset slice sum. - // Update tracked indices. - if (difference == 1.0) { - slice_indices[i].update_true(index, slice); - } else { - slice_indices[i].update_false(index, slice); + std::vector cache_entry; // Initialize the slice cache. + cache_entry.reserve(sum_constraints.size()); + // Get multidimensional indices for `index` so we can identify the slices + // `index` lies on per sum constraint. + const std::vector multi_index = unravel_index(index, node_.shape()); + assert(sum_constraints.size() <= multi_index.size()); + // For each sum constraint. + for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { + const std::optional axis = sum_constraints[i].axis(); + /// Determine the "slice" that index lies on given the sum constraint. + /// If `axis == std::nullopt`, the array is treated as a flat array with a + /// single slice. Otherwise, the slice is defined by multi_index. + assert(!axis.has_value() || *axis < static_cast(multi_index.size())); + const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; + assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); + sum_constraints_lhs[i][slice] += difference; // Offset slice sum. + // Update tracked indices. + if (difference == 1.0) { + slice_indices[i].update_true(index, slice); + } else { + slice_indices[i].update_false(index, slice); + } + cache_entry.push_back(slice); // Record the slice in the cache. } - cache_entry.push_back(slice); // Record the slice in the cache. - } - slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. + slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. } } From 7c05cce149344deda8f87e5f2de99101231b4d06 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 7 Aug 2026 15:39:29 -0700 Subject: [PATCH 24/37] Update NumberNode::set_value() to handle subclasses --- .../dwave-optimization/nodes/numbers.hpp | 45 +--- dwave/optimization/src/nodes/numbers.cpp | 241 +++++++----------- 2 files changed, 101 insertions(+), 185 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index d342961a..ea4c18f2 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -121,6 +121,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // NumberNode methods ***************************************************** + /// @copydoc DecisionNode::assign_from_checkpoint() + void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const override; + void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const override; + /// @copydoc DecisionNode::checkpoint() checkpoint_type checkpoint(State& state) const override; @@ -155,6 +159,15 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::optional> slices = std::nullopt ) const; + // Set the value at the given index in the given state. + // Users may pass the slices (per sum constraint) that each index lies on. + void set_value( + State& state, + ssize_t index, + double value, + std::optional> slices = std::nullopt + ) const; + /// Return the stateless sum constraints. const std::vector& sum_constraints() const; @@ -290,20 +303,6 @@ class IntegerNode : public NumberNode { // @copydoc NumberNode::is_valid() bool is_valid(ssize_t index, double value) const override; - // IntegerNode methods **************************************************** - - /// @copydoc DecisionNode::assign_from_checkpoint() - void assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const override; - void assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const override; - - // Set the value at the given index in the given state. - // Users may pass the slices (per sum constraint) that each index lies on. - void set_value( - State& state, - ssize_t index, - double value, - std::optional> slices = std::nullopt - ) const; protected: // Overloads needed by the Node ABC *************************************** @@ -415,24 +414,6 @@ class BinaryNode : public IntegerNode { return initialize_state(state, std::vector(values.begin(), values.end())); } - /// @copydoc NumberNode::clip_and_set_value() - void clip_and_set_value( - State& state, - ssize_t index, - double value, - std::optional> slices = std::nullopt - ) const; - - /// ** Redefined IntegerNode method since BinaryNode has custom StateData ** - - /// @copydoc IntegerNode::set_value() - void set_value( - State& state, - ssize_t index, - double value, - std::optional> slices = std::nullopt - ) const; - /// ************************** BinaryNode methods ************************** // Flip the value (0 -> 1 or 1 -> 0) at `index` in the given state. diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index af7c3984..49815fbb 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -229,6 +229,30 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat /// Revert the state dependent data of NumberNode. void revert(); + void set( + ssize_t index, + double value, + std::optional> slices + ) { + // We expect `value` to obey the index-wise bounds and integrality + assert(node_.lower_bound(index) <= value); + assert(node_.upper_bound(index) >= value); + assert(not node_.integral() or value == std::round(value)); + + // assert() that i is a valid index occurs in ptr->set(). + // State change occurs IFF `value` != buffer[index]. + if (ArrayNodeStateData::set(index, value)) { + // If change occurred and sum constraint exist, update running sums. + if (node_.sum_constraints().size() > 0) { + if (slices.has_value()) { + update(index, value - diff().back().old, *slices); + } else { + update(index, value - diff().back().old); + } + } + } + } + /// Update the relevant sum constraints running sums (`lhs`) given that the /// value stored at `index` is changed by `difference`. /// Users may pass the slices (per sum constraint) that `index` lies on. @@ -346,6 +370,56 @@ void NumberNodeStateData::update( } } +void NumberNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const { + auto state_data = data_ptr_(state); + + auto* checkpoint_ptr = static_cast(checkpoint.get()); + + assert(checkpoint_ptr == state_data->last_checkpoint()); + + // Check if there are any changes not otherwise tracked by a checkpoint that we need + // to revert first. + // A better way would be to implement a partial revert on our state class, but this + // is not a path we care about greatly so let's err on the side of simple and well- + // tested. + if (ssize_t excess_updates = state_data->diff().size() - checkpoint_ptr->drop()) { + assert(excess_updates > 0); + + for ( + const auto& [idx, old, _] : + state_data->diff() | std::views::reverse | std::views::take(excess_updates) + ) { + state_data->set(idx, old, std::nullopt); + } + } + + auto [updates, optional_slice_cache] = checkpoint_ptr->detach_updates(); + + if (optional_slice_cache.has_value()) { + assert(sum_constraints_.size() > 0); + + auto slices_rit = std::ranges::rbegin(*optional_slice_cache); + + for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { + state_data->set(idx, old, *(slices_rit++)); + } + } else { + assert(updates.empty() or sum_constraints_.empty()); + + // in this case we don't need to do anything to update the slice data + for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { + state_data->set(idx, old, std::nullopt); + } + } + + checkpoint_ptr->drop() = state_data->diff().size(); +} + +void NumberNode::assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const { + assign_from_checkpoint(state, checkpoint); // call the lvalue version + checkpoint.reset(); +} + double const* NumberNode::buff(const State& state) const noexcept { return data_ptr_(state)->buff(); } @@ -737,20 +811,18 @@ void NumberNode::clip_and_set_value( double value, std::optional> slices ) const { - auto state_data = data_ptr_(state); - value = std::clamp(value, lower_bound(index), upper_bound(index)); - // assert() that i is a valid index occurs in ptr->set(). - // State change occurs IFF `value` != buffer[index]. - if (state_data->set(index, value)) { - // If change occurred and sum constraint exist, update running sums. - if (sum_constraints_.size() > 0) { - if (slices.has_value()) { - state_data->update(index, value - diff(state).back().old, *slices); - } else { - state_data->update(index, value - diff(state).back().old); - } - } - } + data_ptr_(state)->set( + index, std::clamp(value, lower_bound(index), upper_bound(index)), std::move(slices) + ); +} + +void NumberNode::set_value( + State& state, + ssize_t index, + double value, + std::optional> slices +) const { + data_ptr_(state)->set(index, value, std::move(slices)); } const std::vector& NumberNode::sum_constraints() const { @@ -1070,59 +1142,6 @@ IntegerNode::IntegerNode( std::move(sum_constraints) ) {} -void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type& checkpoint) const { - auto state_data = data_ptr_(state); - - auto* checkpoint_ptr = static_cast(checkpoint.get()); - - assert(checkpoint_ptr == state_data->last_checkpoint()); - - // Check if there are any changes not otherwise tracked by a checkpoint that we need - // to revert first. - // A better way would be to implement a partial revert on our state class, but this - // is not a path we care about greatly so let's err on the side of simple and well- - // tested. - if (ssize_t excess_updates = state_data->diff().size() - checkpoint_ptr->drop()) { - assert(excess_updates > 0); - - for ( - const auto& [idx, old, _] : - state_data->diff() | std::views::reverse | std::views::take(excess_updates) - ) { - // This is a *very* expensive call. But, again, we're not too worried about - // performance here. - this->set_value(state, idx, old); - } - } - - auto [updates, optional_slice_cache] = checkpoint_ptr->detach_updates(); - - if (optional_slice_cache.has_value()) { - assert(sum_constraints_.size() > 0); - - auto slices_rit = std::ranges::rbegin(*optional_slice_cache); - - for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { - state_data->set(idx, old); - state_data->update(idx, old - diff(state).back().old, *(slices_rit++)); - } - } else { - assert(updates.empty() or sum_constraints_.empty()); - - // in this case we don't need to do anything to update the slice data - for (const auto& [idx, old, _] : std::move(updates) | std::views::reverse) { - state_data->set(idx, old); - } - } - - checkpoint_ptr->drop() = state_data->diff().size(); -} - -void IntegerNode::assign_from_checkpoint(State& state, checkpoint_type&& checkpoint) const { - assign_from_checkpoint(state, checkpoint); // call the lvalue version - checkpoint.reset(); -} - bool IntegerNode::integral() const { return true; } bool IntegerNode::is_valid(ssize_t index, double value) const { @@ -1130,31 +1149,6 @@ bool IntegerNode::is_valid(ssize_t index, double value) const { (std::round(value) == value); } -void IntegerNode::set_value( - State& state, - ssize_t index, - double value, - std::optional> slices -) const { - auto state_data = data_ptr_(state); - // We expect `value` to obey the index-wise bounds and to be an integer. - assert(lower_bound(index) <= value); - assert(upper_bound(index) >= value); - assert(value == std::round(value)); - // assert() that i is a valid index occurs in ptr->set(). - // State change occurs IFF `value` != buffer[index]. - if (state_data->set(index, value)) { - // If change occurred and sum constraint exist, update running sums. - if (sum_constraints_.size() > 0) { - if (slices.has_value()) { - state_data->update(index, value - diff(state).back().old, *slices); - } else { - state_data->update(index, value - diff(state).back().old); - } - } - } -} - double IntegerNode::default_value(ssize_t index) const { return (lower_bound(index) <= 0 && upper_bound(index) >= 0) ? 0 : lower_bound(index); } @@ -1651,53 +1645,6 @@ void BinaryNode::initialize_state(State& state) const { } } -void BinaryNode::clip_and_set_value( - State& state, - ssize_t index, - double value, - std::optional> slices -) const { - auto state_data = data_ptr_(state); - value = std::clamp(value, lower_bound(index), upper_bound(index)); - // assert() that i is a valid index occurs in ptr->set(). - // State change occurs IFF `value` != buffer[index]. - if (state_data->set(index, value)) { - // If change occurred and sum constraint exist, update running sums. - if (sum_constraints_.size() > 0) { - if (slices.has_value()) { - state_data->update(index, value - diff(state).back().old, *slices); - } else { - state_data->update(index, value - diff(state).back().old); - } - } - } -} - -void BinaryNode::set_value( - State& state, - ssize_t index, - double value, - std::optional> slices -) const { - auto state_data = data_ptr_(state); - // We expect `value` to obey the index-wise bounds and to be an integer. - assert(lower_bound(index) <= value); - assert(upper_bound(index) >= value); - assert(value == std::round(value)); - // assert() that i is a valid index occurs in ptr->set(). - // State change occurs IFF `value` != buffer[index]. - if (state_data->set(index, value)) { - // If change occurred and sum constraint exist, update running sums. - if (sum_constraints_.size() > 0) { - if (slices.has_value()) { - state_data->update(index, value - diff(state).back().old, *slices); - } else { - state_data->update(index, value - diff(state).back().old); - } - } - } -} - void BinaryNode::flip( State& state, ssize_t index, @@ -1706,20 +1653,8 @@ void BinaryNode::flip( auto state_data = data_ptr_(state); // Variable should not be fixed. assert(lower_bound(index) != upper_bound(index)); - // assert() that `index` is valid occurs in ptr->set(). - // State change occurs IFF `value` != buffer[index]. - if (state_data->set(index, !state_data->get(index))) { - // If change occurred and sum constraint exist, update running sums. - if (sum_constraints_.size() > 0) { - // If value changed from 0 -> 1, update by 1. - // If value changed from 1 -> 0, update by -1. - if (slices.has_value()) { - state_data->update(index, (state_data->get(index) == 1) ? 1 : -1, *slices); - } else { - state_data->update(index, (state_data->get(index) == 1) ? 1 : -1); - } - } - } + + state_data->set(index, not state_data->get(index), std::move(slices)); } ssize_t BinaryNode::num_true( From 8bf618ffcdc824b6112110b5e04d0cee8979d28d Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Fri, 7 Aug 2026 15:45:55 -0700 Subject: [PATCH 25/37] Make NumberNodeStateData::update() private --- dwave/optimization/src/nodes/numbers.cpp | 51 ++++++++++++------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 49815fbb..85855051 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -208,15 +208,15 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat if (i_slices.has_value()) { assert(j_slices.has_value()); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - update(i, difference, *i_slices); + update_(i, difference, *i_slices); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - update(j, -difference, *j_slices); + update_(j, -difference, *j_slices); } else { assert(!j_slices.has_value()); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - update(i, difference); + update_(i, difference); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - update(j, -difference); + update_(j, -difference); } } } @@ -245,23 +245,14 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat // If change occurred and sum constraint exist, update running sums. if (node_.sum_constraints().size() > 0) { if (slices.has_value()) { - update(index, value - diff().back().old, *slices); + update_(index, value - diff().back().old, *slices); } else { - update(index, value - diff().back().old); + update_(index, value - diff().back().old); } } } } - /// Update the relevant sum constraints running sums (`lhs`) given that the - /// value stored at `index` is changed by `difference`. - /// Users may pass the slices (per sum constraint) that `index` lies on. - virtual void update( - ssize_t index, - double difference, - std::optional> optional_slices = std::nullopt - ); - /// For each sum constraint, track the sum of the values within each slice. /// `sum_constraints_lhs[i][j]` is the sum of the values within the `j`th slice /// along the `axis`* defined by the `i`th sum constraint. @@ -281,6 +272,16 @@ class NumberNodeStateData : public ArrayNodeStateData, public CheckpointableStat /// Hold a reference to the parent node. This class can outlive the node but /// cannot be accessed except through it. const NumberNode& node_; + + private: + /// Update the relevant sum constraints running sums (`lhs`) given that the + /// value stored at `index` is changed by `difference`. + /// Users may pass the slices (per sum constraint) that `index` lies on. + virtual void update_( + ssize_t index, + double difference, + std::optional> optional_slices = std::nullopt + ); }; void NumberNodeStateData::revert() { @@ -316,7 +317,7 @@ void NumberNodeStateData::revert() { assert(slice_cache_.empty()); } -void NumberNodeStateData::update( +void NumberNodeStateData::update_( const ssize_t index, const double difference, std::optional> optional_slices @@ -1444,21 +1445,21 @@ struct BinaryNodeStateData : public NumberNodeStateData { /// Revert the state dependent data of BinaryNode. void revert(); + /// A collection of DisjointSparseSet, one per sum constraint. + std::vector slice_indices; + + private: + /// Populate `slice_indices` given the BinaryNode and its assigned values. + void compute_slice_indices_(const BinaryNode& node); + /// Update `sum_constraints_lhs` and `slice_indices` given that the value /// stored at `index` is changed by `difference`. /// Users may pass the slices (per sum constraint) that `index` lies on. - void update( + void update_( ssize_t index, double difference, std::optional> optional_slices = std::nullopt ) override; - - /// A collection of DisjointSparseSet, one per sum constraint. - std::vector slice_indices; - - private: - /// Populate `slice_indices` given the BinaryNode and its assigned values. - void compute_slice_indices_(const BinaryNode& node); }; void BinaryNodeStateData::revert() { @@ -1493,7 +1494,7 @@ void BinaryNodeStateData::revert() { ArrayNodeStateData::revert(); // Revert changes to the buffer. } -void BinaryNodeStateData::update( +void BinaryNodeStateData::update_( const ssize_t index, const double difference, std::optional> optional_slices From a27e9b7ae6e267364d321fa3ac4c1e3ff3a3d196 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 10 Aug 2026 14:53:49 -0700 Subject: [PATCH 26/37] Add dependency-groups for oldest dependencies --- .circleci/config.yml | 57 +++++++++++++------ pyproject.toml | 36 +++++++----- .../oldest-dependencies-3929e0fed0bb430a.yaml | 6 ++ 3 files changed, 68 insertions(+), 31 deletions(-) create mode 100644 releasenotes/notes/oldest-dependencies-3929e0fed0bb430a.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index b66c34b2..0ec38795 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -92,7 +92,6 @@ jobs: name: test with installed package command: | . env/bin/activate - pip install --group test cd tests/ python -Werror -m unittest @@ -154,9 +153,7 @@ jobs: parameters: python-version: type: string - dependency-versions: - type: string - optional-dependency-versions: + wheel-tag: type: string docker: @@ -167,23 +164,49 @@ jobs: - attach_workspace: at: dist - run: - name: install package with dependencies + name: test with latest required dependencies command: | python -m venv env . env/bin/activate pip install pip --upgrade - pip install --upgrade --only-binary=:all: \ - << parameters.dependency-versions >> - pip install dwave-optimization --no-index -f dist/ --no-deps --force-reinstall - - run: *run-tests + pip install dist/dwave*optimization-*-<< parameters.wheel-tag >>-*.whl + cd tests/ + python -Werror -m unittest + rm -r ../env - run: - name: install optional dependencies + name: test with latest optional dependencies command: | + python -m venv env . env/bin/activate - pip install --upgrade --only-binary=:all: \ - << parameters.dependency-versions >> \ - << parameters.optional-dependency-versions >> \ - - run: *run-tests + pip install pip --upgrade + pip install "$(echo dist/dwave*optimization-*-<< parameters.wheel-tag >>-*.whl)[all]" + cd tests/ + python -Werror -m unittest + rm -r ../env + - run: + name: test with oldest required dependencies + command: | + python -m venv env + . env/bin/activate + pip install pip --upgrade + pip install \ + --group dependencies-oldest \ + dist/dwave*optimization-*-<< parameters.wheel-tag >>-*.whl + cd tests/ + python -Werror -m unittest + rm -r ../env + - run: + name: test with oldest optional dependencies + command: | + python -m venv env + . env/bin/activate + pip install pip --upgrade + pip install \ + --group dependencies-all-oldest \ + dist/dwave*optimization-*-<< parameters.wheel-tag >>-*.whl + cd tests/ + python -Werror -m unittest + rm -r ../env cpp-gcc: parameters: @@ -377,15 +400,13 @@ workflows: - python-dependencies: name: python-dependencies-oldest python-version: "3.10" - dependency-versions: numpy==1.21.3 - optional-dependency-versions: dimod==0.12.0 scikit-learn==1.6.0 + wheel-tag: cp310-cp310 requires: - python-linux - python-dependencies: name: python-dependencies-latest python-version: "3.14" - dependency-versions: numpy - optional-dependency-versions: dimod scikit-learn + wheel-tag: cp312-abi3 requires: - python-linux - cpp-gcc: diff --git a/pyproject.toml b/pyproject.toml index ab59e4c0..26808904 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,13 +33,13 @@ classifiers = [ 'Programming Language :: Python :: Implementation :: CPython', ] requires-python = ">=3.10" -dependencies = [ - "numpy>=1.21.3", # oldest that supports Python 3.10 +dependencies = [ # Keep synced with dependency-groups.dependencies-oldest + "numpy>=2", ] [project.optional-dependencies] -all = [ - "dimod>=0.12.0", # oldest minor that passes tests +all = [ # Keep synced with dependency-groups.dependencies-all-oldest + "dimod>=0.12.16", # oldest dimod that supports NumPy>=2 "scikit-learn>=1.6.0", # oldest minor that passes tests ] @@ -74,13 +74,29 @@ changelog = [ clang-format = [ "clang-format~=22.0", # latest as of April 2026, we require >=22 ] +dependencies-oldest = [ # Keep synced with project.dependencies + "numpy==2.0.0; python_version<'3.13'", # oldest we support + "numpy==2.1.0; python_version>='3.13' and python_version<'3.14'", + "numpy==2.3.2; python_version>='3.14' and python_version<'3.15'", + "numpy; python_version>='3.15'", # once 3.15 releases we'll lock this down +] +dependencies-all-oldest = [ # Keep synced with project.optional-dependencies + {include-group = "dependencies-oldest"}, + + "dimod==0.12.16; python_version<'3.13'", # oldest we support + "dimod==0.12.17; python_version>='3.13' and python_version<'3.14'", + "dimod==0.12.21; python_version>='3.14' and python_version<'3.15'", + "dimod; python_version>='3.15'", # once 3.15 releases we'll lock this down + + "scikit-learn==1.6.0; python_version<'3.14'", # oldest we support + "scikit-learn==1.8.0; python_version>='3.14' and python_version<'3.15'", + "scikit-learn; python_version>='3.15'", # once 3.15 releases we'll lock this down +] dev = [ - # Install requirements - "numpy==2.2.6; python_version < '3.11'", # last that supports 3.10 - "numpy==2.3.3; python_version >= '3.11'", {include-group = "build"}, {include-group = "changelog"}, {include-group = "clang-format"}, + {include-group = "dependencies-oldest"}, ] docs = [ # Matches dwave-ocean-sdk as of Dec 2025 @@ -89,11 +105,6 @@ docs = [ "sphinx-design==0.6.1", "breathe==4.35.0", ] -test = [ - "dimod==0.12.21", - "scikit-learn==1.7.2; python_version<'3.11'", # last that supports 3.10 - "scikit-learn==1.8.0; python_version>='3.11'", # first that supports 3.14t -] [tool.cibuildwheel] build-verbosity = "1" @@ -105,7 +116,6 @@ skip = "pp* *musllinux*" before-build = "pip install --group blas-build" build-frontend = { name = "build", args = ["--no-isolation"] } -before-test = "pip install --group test" test-command = "python -Werror -m unittest discover {project}/tests/" [tool.cibuildwheel.config-settings] diff --git a/releasenotes/notes/oldest-dependencies-3929e0fed0bb430a.yaml b/releasenotes/notes/oldest-dependencies-3929e0fed0bb430a.yaml new file mode 100644 index 00000000..4ae1eff1 --- /dev/null +++ b/releasenotes/notes/oldest-dependencies-3929e0fed0bb430a.yaml @@ -0,0 +1,6 @@ +--- +features: + - Add ``dependencies-oldest`` and ``dependencies-all-oldest`` dependency groups. +upgrade: + - Require ``Numpy>=2``. + - Remove the ``test`` dependency group. From 67cd2b8dc82fc539f4e70f0e7f9455bad6dad255 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Mon, 10 Aug 2026 11:18:04 -0700 Subject: [PATCH 27/37] Use SciPy for LinearProgram symbol calculations when available --- .../include/dwave-optimization/nodes/lp.hpp | 16 ++- dwave/optimization/mathematical.py | 30 +++-- dwave/optimization/src/nodes/lp.cpp | 67 ++++++++-- dwave/optimization/symbols/lp.pyi | 2 +- dwave/optimization/symbols/lp.pyx | 120 ++++++++++++++++-- pyproject.toml | 6 + .../notes/lp-fallback-23a5cd1a2704a2d3.yaml | 8 ++ tests/cpp/nodes/test_lp.cpp | 64 ++++++++++ tests/test_symbols.py | 58 +++++++++ 9 files changed, 336 insertions(+), 35 deletions(-) create mode 100644 releasenotes/notes/lp-fallback-23a5cd1a2704a2d3.yaml diff --git a/dwave/optimization/include/dwave-optimization/nodes/lp.hpp b/dwave/optimization/include/dwave-optimization/nodes/lp.hpp index 8c67313f..d364d8d7 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/lp.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/lp.hpp @@ -102,6 +102,17 @@ class LinearProgramNodeBase : public Node { /// callback=None, options=None, x0=None, integrality=None) class LinearProgramNode : public EqualityMixin { public: + using linprog_type = std::function( + std::span c, + std::span b_lb, + std::span A, + std::span b_ub, + std::span A_eq, + std::span b_eq, + std::span lb, + std::span ub + )>; + /// Construct a LinearProgramNode /// /// Note: parameter names are chosen to match scipy.optimize.lingprog() @@ -113,7 +124,8 @@ class LinearProgramNode : public EqualityMixin variables_minmax_; }; diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index f3d650c3..08a4c59a 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1240,15 +1240,15 @@ def less_equal(x1: ArraySymbolLike, x2: ArraySymbolLike) -> LessEqual: def linprog( c: ArraySymbol, - A_ub: None | ArraySymbol = None, # alias for A - b_ub: None | ArraySymbol = None, - A_eq: None | ArraySymbol = None, - b_eq: None | ArraySymbol = None, + A_ub: None | ArraySymbolLike = None, # alias for A + b_ub: None | ArraySymbolLike = None, + A_eq: None | ArraySymbolLike = None, + b_eq: None | ArraySymbolLike = None, *, # the args up until here match SciPy's linprog() which accepts them positionally - b_lb: None | ArraySymbol = None, - A: None | ArraySymbol = None, - lb: None | ArraySymbol = None, - ub: None | ArraySymbol = None, + b_lb: None | ArraySymbolLike = None, + A: None | ArraySymbolLike = None, + lb: None | ArraySymbolLike = None, + ub: None | ArraySymbolLike = None, ) -> LPResult: r"""Solve a :term:`linear program`. @@ -1320,13 +1320,25 @@ def linprog( :func:`~scipy.optimize.linprog`: :doc:`SciPy ` function .. versionadded:: 0.6.0 + .. versionchanged:: 0.7.3 + Starting in version `0.7.3`, if SciPy is installed then it will be used + to calculate the outputs of the linear program. """ + # Handle the A/A_ub alias. if A is not None and A_ub is not None: raise ValueError("can provide A or A_ub, but not both") elif A_ub is not None: A = A_ub - return LPResult(LinearProgram(c, b_lb, A, b_ub, A_eq, b_eq, lb, ub)) + # Transform array-likes into ArraySymbols + inputs = dict(c=c, b_lb=b_lb, A=A, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, lb=lb, ub=ub) + inputs = dict((k, v) for (k, v) in inputs.items() if v is not None) # filter out the Nones + keys = list(inputs) + values = as_array_symbols(*(inputs[k] for k in keys)) # these should now all be ArraySymbols + kwargs = dict(zip(keys, values)) + + # Finally, create the symbol and return a structure that's similar to SciPy's OptimizeResult + return LPResult(LinearProgram(**kwargs)) def log(x: ArraySymbol) -> Log: diff --git a/dwave/optimization/src/nodes/lp.cpp b/dwave/optimization/src/nodes/lp.cpp index ddf281f4..b1f06f2c 100644 --- a/dwave/optimization/src/nodes/lp.cpp +++ b/dwave/optimization/src/nodes/lp.cpp @@ -182,7 +182,8 @@ LinearProgramNode::LinearProgramNode( ArrayNode* A_eq_ptr, ArrayNode* b_eq_ptr, ArrayNode* lb_ptr, - ArrayNode* ub_ptr + ArrayNode* ub_ptr, + linprog_type lingprog_ ) : c_ptr_(c_ptr), b_lb_ptr_(b_lb_ptr), @@ -192,6 +193,7 @@ LinearProgramNode::LinearProgramNode( b_eq_ptr_(b_eq_ptr), lb_ptr_(lb_ptr), ub_ptr_(ub_ptr), + linprog_(lingprog_), variables_minmax_( lb_ptr_ ? lb_ptr_->min() : LinearProgramNode::default_lower_bound(), ub_ptr_ ? ub_ptr_->max() : LinearProgramNode::default_upper_bound() @@ -307,10 +309,16 @@ void LinearProgramNode::readout_predecessor_data(const State& state, LPData& lp) void LinearProgramNode::initialize_state(State& state) const { LPData lp; readout_predecessor_data(state, lp); + + if (linprog_) { + return initialize_state( + state, linprog_(lp.c, lp.b_lb, lp.A, lp.b_ub, lp.A_eq, lp.b_eq, lp.lb, lp.ub) + ); + } + SolveResult result = linprog( lp.c, lp.b_lb, lp.A, lp.b_ub, lp.A_eq, lp.b_eq, lp.lb, lp.ub, FEASIBILITY_TOLERANCE ); - emplace_data_ptr_(state, std::move(result)); } @@ -346,17 +354,50 @@ void LinearProgramNode::propagate(State& state) const { auto data = data_ptr_(state); readout_predecessor_data(state, data->lp); - data->result = linprog( - data->lp.c, - data->lp.b_lb, - data->lp.A, - data->lp.b_ub, - data->lp.A_eq, - data->lp.b_eq, - data->lp.lb, - data->lp.ub, - FEASIBILITY_TOLERANCE - ); + + SolveResult result; + + if (linprog_) { + // If the user has specified a linprog function, use that + auto solution = linprog_( + data->lp.c, + data->lp.b_lb, + data->lp.A, + data->lp.b_ub, + data->lp.A_eq, + data->lp.b_eq, + data->lp.lb, + data->lp.ub + ); + + result.set_solution( + std::move(solution), + data->lp.c, + data->lp.b_lb, + data->lp.A, + data->lp.b_ub, + data->lp.A_eq, + data->lp.b_eq, + data->lp.lb, + data->lp.ub, + FEASIBILITY_TOLERANCE + ); + } else { + // otherwise, use our simplex method + result = linprog( + data->lp.c, + data->lp.b_lb, + data->lp.A, + data->lp.b_ub, + data->lp.A_eq, + data->lp.b_eq, + data->lp.lb, + data->lp.ub, + FEASIBILITY_TOLERANCE + ); + } + + data->result = result; Node::propagate(state); } diff --git a/dwave/optimization/symbols/lp.pyi b/dwave/optimization/symbols/lp.pyi index cad65438..067311a1 100644 --- a/dwave/optimization/symbols/lp.pyi +++ b/dwave/optimization/symbols/lp.pyi @@ -19,7 +19,7 @@ from dwave.optimization.model import ArraySymbol, Symbol class LinearProgram(Symbol): def feasible(self, index: int = 0) -> bool: ... def objective_value(self, index: int = 0) -> float: ... - def state(self, index: int = 0) -> numpy.typing.NDArray: ... + def state(self, index: int = 0) -> numpy.typing.NDArray[numpy.double]: ... class LinearProgramFeasible(ArraySymbol): ... class LinearProgramObjectiveValue(ArraySymbol): ... diff --git a/dwave/optimization/symbols/lp.pyx b/dwave/optimization/symbols/lp.pyx index f1b6f02e..b4e967a2 100644 --- a/dwave/optimization/symbols/lp.pyx +++ b/dwave/optimization/symbols/lp.pyx @@ -17,8 +17,14 @@ import json import numpy as np - from cython.operator cimport typeid +from libcpp.span cimport span +from libcpp.vector cimport vector + +try: + from scipy.optimize import linprog +except ImportError: + linprog = None from dwave.optimization._model cimport _Graph, _register, ArraySymbol, Symbol from dwave.optimization._utilities cimport as_span @@ -34,6 +40,87 @@ from dwave.optimization.libcpp.nodes.lp cimport ( from dwave.optimization.states cimport States +cdef object _as_array(span[const double] sp): + if not sp.size(): + return None + return np.asarray(sp.data()) + + +cdef object _parse_A_ub( + span[const double] _b_lb, + span[const double] _A, + span[const double] _b_ub, +): + b_lb = _as_array(_b_lb) + + # if one is empty, they all are + if b_lb is None: + return dict() + + A = _as_array(_A).reshape(b_lb.size, -1) + b_ub = _as_array(_b_ub) + + # We need to convert to only upper bounds + A_ub = np.vstack((A, -A)) + b_ub = np.hstack((b_ub, -b_lb)) + + # we can have infinities sometimes, so let's drop those + if (b_ub == np.inf).any(): + A_ub = A_ub[b_ub != np.inf, :] + b_ub = b_ub[b_ub != np.inf] + + return dict(A_ub=A_ub, b_ub=b_ub) + + +cdef object _parse_A_eq( + span[const double] _A_eq, + span[const double] _b_eq, +): + A_eq = _as_array(_A_eq) + + if A_eq is None: + return dict() + + b_eq = _as_array(_b_eq) + + return dict(A_eq=A_eq.reshape(b_eq.size, -1), b_eq=b_eq) + + +cdef object _parse_bounds( + span[const double] lb, + span[const double] ub, +): + return np.vstack((_as_array(lb), _as_array(ub))).T + + +# We currently don't allow the user to specify the `method` kwarg to linprog() +# because getting that information into this function is tricky. A sketch would +# be to use `std::bind()`, but Cython finds that kind of function quite +# challenging so for simplicity we just use the default. +cdef vector[double] _linprog( + span[const double] c, + span[const double] b_lb, + span[const double] A, + span[const double] b_ub, + span[const double] A_eq, + span[const double] b_eq, + span[const double] lb, + span[const double] ub, +): + res = linprog( + c=_as_array(c), + **_parse_A_ub(b_lb, A, b_ub), + **_parse_A_eq(A_eq, b_eq), + bounds=_parse_bounds(lb, ub) + ) + + cdef vector[double] x # output + for x_i in res.x: + x.push_back(x_i) + + return x + + cdef class LinearProgram(Symbol): """Solves a linear program (LP) defined by the predecessors. @@ -64,14 +151,17 @@ cdef class LinearProgram(Symbol): .. versionadded:: 0.6.0 """ - def __init__(self, ArraySymbol c, - ArraySymbol b_lb = None, - ArraySymbol A = None, - ArraySymbol b_ub = None, - ArraySymbol A_eq = None, - ArraySymbol b_eq = None, - ArraySymbol lb = None, - ArraySymbol ub = None): + def __init__( + self, + ArraySymbol c, + ArraySymbol b_lb = None, + ArraySymbol A = None, + ArraySymbol b_ub = None, + ArraySymbol A_eq = None, + ArraySymbol b_eq = None, + ArraySymbol lb = None, + ArraySymbol ub = None, + ): cdef _Graph model = c.model cdef ArrayNode* c_ptr = c.array_ptr @@ -86,8 +176,16 @@ cdef class LinearProgram(Symbol): cdef ArrayNode* lb_ptr = LinearProgram.as_arraynodeptr(model, lb) cdef ArrayNode* ub_ptr = LinearProgram.as_arraynodeptr(model, ub) - self.ptr = model._graph.emplace_node[LinearProgramNode]( - c_ptr, b_lb_ptr, A_ptr, b_ub_ptr, A_eq_ptr, b_eq_ptr, lb_ptr, ub_ptr) + # In the future we could support a `method` kwarg and add a `dwopt-simplex` option + # or something similar. See note on _linprog above. + if linprog: + self.ptr = model._graph.emplace_node[LinearProgramNode]( + c_ptr, b_lb_ptr, A_ptr, b_ub_ptr, A_eq_ptr, b_eq_ptr, lb_ptr, ub_ptr, _linprog + ) + else: + self.ptr = model._graph.emplace_node[LinearProgramNode]( + c_ptr, b_lb_ptr, A_ptr, b_ub_ptr, A_eq_ptr, b_eq_ptr, lb_ptr, ub_ptr) + self.initialize_node(model, self.ptr) @staticmethod diff --git a/pyproject.toml b/pyproject.toml index 26808904..638ec7db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ # Keep synced with dependency-groups.dependencies-oldest all = [ # Keep synced with dependency-groups.dependencies-all-oldest "dimod>=0.12.16", # oldest dimod that supports NumPy>=2 "scikit-learn>=1.6.0", # oldest minor that passes tests + "scipy>=1.13.0", # oldest SciPy that support NumPy>=2 ] [project.readme] @@ -91,6 +92,11 @@ dependencies-all-oldest = [ # Keep synced with project.optional-dependencies "scikit-learn==1.6.0; python_version<'3.14'", # oldest we support "scikit-learn==1.8.0; python_version>='3.14' and python_version<'3.15'", "scikit-learn; python_version>='3.15'", # once 3.15 releases we'll lock this down + + "scipy==1.13.0; python_version<'3.13'", # first that supports NumPy 2 + "scipy==1.14.1; python_version>='3.13' and python_version<'3.14'", + "scipy==1.16.3; python_version>='3.14' and python_version<'3.15'", + "scipy; python_version>='3.15'", # once 3.15 releases we'll lock this down ] dev = [ {include-group = "build"}, diff --git a/releasenotes/notes/lp-fallback-23a5cd1a2704a2d3.yaml b/releasenotes/notes/lp-fallback-23a5cd1a2704a2d3.yaml new file mode 100644 index 00000000..9a3a7638 --- /dev/null +++ b/releasenotes/notes/lp-fallback-23a5cd1a2704a2d3.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + Use SciPy's ``scipy.optimize.linprog()`` function to calculate the state of + the ``LinearProgram`` symbol when SciPy is available. + See `#599 `_. + - | + Accept ``ArraySymbolLike`` inputs to ``linprog()`` function. diff --git a/tests/cpp/nodes/test_lp.cpp b/tests/cpp/nodes/test_lp.cpp index f28850c8..e7ab4c18 100644 --- a/tests/cpp/nodes/test_lp.cpp +++ b/tests/cpp/nodes/test_lp.cpp @@ -743,6 +743,70 @@ TEST_CASE("LinearProgramNode") { } } + GIVEN("A two variable, two row LP, and a user-defined linprog() function") { + // min: -x0 + 4x1 + // such that: + // -3x0 + x1 <= 6 + // -x0 - 2x1 >= -4 + // x1 >= -3 + + ssize_t count = 0; // the number of times our linprog method has been called + auto linprog = [&count]( + std::span c, + std::span b_lb, + std::span A, + std::span b_ub, + std::span A_eq, + std::span b_eq, + std::span lb, + std::span ub + ) -> std::vector { + ++count; + return std::vector(c.begin(), c.end()); + }; + + auto graph = Graph(); + + // will be c = [-1, 4] once we initialize the state + auto c_ptr = graph.emplace_node(2, -10, 10); // we want to vary this + + // A_ub = [[-3, 1], [1, 2]], b_ub = [6, 4] + auto A_ub = std::vector{-3, 1, 1, 2}; + auto A_ub_ptr = graph.emplace_node(A_ub.data(), std::vector{2, 2}); + + // b_ub = [6, 4] + auto b_ub_ptr = graph.emplace_node(std::vector{6, 4}); + + // lb = [-inf, -3] + auto lb_ptr = + graph.emplace_node(std::vector{-LinearProgramNode::infinity(), -3.0}); + + auto lp_ptr = graph.emplace_node( + c_ptr, nullptr, A_ub_ptr, b_ub_ptr, nullptr, nullptr, lb_ptr, nullptr, linprog + ); + + auto feas_ptr = graph.emplace_node(lp_ptr); + auto obj_ptr = graph.emplace_node(lp_ptr); + auto sol_ptr = graph.emplace_node(lp_ptr); + + graph.emplace_node(feas_ptr); + graph.emplace_node(obj_ptr); + graph.emplace_node(sol_ptr); + + auto state = graph.empty_state(); + c_ptr->initialize_state(state, {-1, 4}); + graph.initialize_state(state); + + CHECK(count == 1); + CHECK_THAT(sol_ptr->view(state), RangeEquals(std::vector{-1, 4})); + + c_ptr->set_value(state, 0, 2); + graph.propagate(state); + + CHECK(count == 2); + CHECK_THAT(sol_ptr->view(state), RangeEquals(std::vector{2, 4})); + } + SECTION("equality") { auto graph = Graph(); diff --git a/tests/test_symbols.py b/tests/test_symbols.py index dd71a856..8ea3ce41 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -19,6 +19,7 @@ import sys import typing import unittest +import unittest.mock import numpy as np @@ -2741,6 +2742,63 @@ def test_serialization_with_states(self): self.assertFalse(lp.has_state(2)) np.testing.assert_array_equal(lp.state(3), [1, 1]) + def test_fallback(self): + expected_c = np.asarray([1, 2, 3]) + expected_A_eq = np.asarray([[4, 5, 6], [7, 8, 9]]) + expected_b_eq = np.asarray([1, 2]) + expected_lb = np.asarray([-1, -2, -3]) + expected_ub = np.asarray([100, 101, 102]) + expected_x = np.asarray([50, 51, 52], dtype=np.double) + count = [0] + + def mock_linprog(**kwargs): + count[0] += 1 + + np.testing.assert_array_equal(kwargs['c'], expected_c) + np.testing.assert_array_equal(kwargs['A_eq'], expected_A_eq) + np.testing.assert_array_equal(kwargs['b_eq'], expected_b_eq) + np.testing.assert_array_equal( + kwargs['bounds'], + np.vstack((expected_lb, expected_ub)).T, + ) + + self.assertEqual(len(kwargs), 4) + + class Res: + pass + + res = Res() + res.x = expected_x + + return res + + with unittest.mock.patch("dwave.optimization.symbols.lp.linprog", new=mock_linprog): + model = Model() + model.states.resize(1) + + c = model.integer(3) + c.set_state(0, expected_c) + + x = dwave.optimization.linprog( + c, + A_eq=expected_A_eq, + b_eq=expected_b_eq, + lb=expected_lb, + ub=expected_ub, + ).x + + with model.lock(): + np.testing.assert_array_equal(x.state(), expected_x) + + # having established that the fallback works, let's make sure we're + # pointing to scipy + try: + import scipy + except ImportError: + return # all done + + self.assertIs(dwave.optimization.symbols.lp.linprog, scipy.optimize.linprog) + class TestMatrixMultiply(utils.SymbolTests): def generate_symbols(self): From 55c5bbc1fcdbf9ce3169cfd5d374841047425cf6 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 11 Aug 2026 11:02:51 -0700 Subject: [PATCH 28/37] Build wheels for Python 3.15 Also use the latest cibuildwheel --- .circleci/config.yml | 4 ++-- pyproject.toml | 17 +++++++---------- .../notes/python3.15-6983caf4e8172c2e.yaml | 3 +++ 3 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 releasenotes/notes/python3.15-6983caf4e8172c2e.yaml diff --git a/.circleci/config.yml b/.circleci/config.yml index 0ec38795..549a8b57 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ commands: parameters: cibw-version: type: string - default: 3.4.1 # latest as of April 2026 + default: 4.2.0 # latest as of August 2026 steps: - run: name: run cibuildwheel @@ -405,7 +405,7 @@ workflows: - python-linux - python-dependencies: name: python-dependencies-latest - python-version: "3.14" + python-version: "3.14" # As of August 11 2026, dimod, sklearn, scipy don't have 3.15 wheels wheel-tag: cp312-abi3 requires: - python-linux diff --git a/pyproject.toml b/pyproject.toml index 638ec7db..9e81449e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,8 @@ dependencies-oldest = [ # Keep synced with project.dependencies "numpy==2.0.0; python_version<'3.13'", # oldest we support "numpy==2.1.0; python_version>='3.13' and python_version<'3.14'", "numpy==2.3.2; python_version>='3.14' and python_version<'3.15'", - "numpy; python_version>='3.15'", # once 3.15 releases we'll lock this down + "numpy==2.5.2; python_version>='3.15' and python_version<'3.16'", + "numpy; python_version>='3.16'", # once 3.16 releases we'll lock this down ] dependencies-all-oldest = [ # Keep synced with project.optional-dependencies {include-group = "dependencies-oldest"}, @@ -87,16 +88,16 @@ dependencies-all-oldest = [ # Keep synced with project.optional-dependencies "dimod==0.12.16; python_version<'3.13'", # oldest we support "dimod==0.12.17; python_version>='3.13' and python_version<'3.14'", "dimod==0.12.21; python_version>='3.14' and python_version<'3.15'", - "dimod; python_version>='3.15'", # once 3.15 releases we'll lock this down + "dimod; python_version>='3.15'", # once dimod has a 3.15 release we'll lock this down "scikit-learn==1.6.0; python_version<'3.14'", # oldest we support "scikit-learn==1.8.0; python_version>='3.14' and python_version<'3.15'", - "scikit-learn; python_version>='3.15'", # once 3.15 releases we'll lock this down + "scikit-learn; python_version>='3.15'", # once scikit-learn has a 3.15 release we'll lock this down "scipy==1.13.0; python_version<'3.13'", # first that supports NumPy 2 "scipy==1.14.1; python_version>='3.13' and python_version<'3.14'", "scipy==1.16.3; python_version>='3.14' and python_version<'3.15'", - "scipy; python_version>='3.15'", # once 3.15 releases we'll lock this down + "scipy; python_version>='3.15'", # once scipy has a 3.15 release we'll lock this down ] dev = [ {include-group = "build"}, @@ -114,7 +115,7 @@ docs = [ [tool.cibuildwheel] build-verbosity = "1" -skip = "pp* *musllinux*" +skip = "*musllinux*" # When building with cibuildwheel, we want scipy_openblas64 available to the # build step. There isn't (as of Dec 2025) a way to override the @@ -139,6 +140,7 @@ archs = "x86_64 arm64" environment = "MACOSX_DEPLOYMENT_TARGET=10.13" [tool.cibuildwheel.windows] +# cibuildwheel does delvewheel automatically, but we need to customize the command archs = "AMD64" repair-wheel-command = [ "pip install delvewheel", @@ -151,11 +153,6 @@ repair-wheel-command = [ # wheel. 313t and 314t do not support abi wheels yet. select = "cp31[!01]-*" # uses fnmatch, will fail for 3.20+ but good enough for now build-frontend = { name = "build", args = ["-Csetup-args=-Dpython.allow_limited_api=true", "--no-isolation"] } -inherit.repair-wheel-command = "append" -repair-wheel-command = [ - "pip install abi3audit", - "abi3audit --strict --report {wheel}", -] [[tool.cibuildwheel.overrides]] select = "*-????linux_*" diff --git a/releasenotes/notes/python3.15-6983caf4e8172c2e.yaml b/releasenotes/notes/python3.15-6983caf4e8172c2e.yaml new file mode 100644 index 00000000..92b7123c --- /dev/null +++ b/releasenotes/notes/python3.15-6983caf4e8172c2e.yaml @@ -0,0 +1,3 @@ +--- +features: + - Build wheels for Python 3.15. From 450b9f4693a0a0eb6356c1d15667d156ba1fb799 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Mon, 3 Aug 2026 16:59:45 -0400 Subject: [PATCH 29/37] Add C++ `IsDisjointCoverNode` and python `is_disjoint_cover` function --- dwave/optimization/generators.py | 24 +- .../include/dwave-optimization/nodes.hpp | 1 + .../dwave-optimization/nodes/set_routines.hpp | 39 +++ .../libcpp/nodes/set_routines.pxd | 3 + dwave/optimization/mathematical.py | 48 +++ dwave/optimization/model.py | 9 + dwave/optimization/src/nodes/set_routines.cpp | 176 ++++++++++ dwave/optimization/symbols/__init__.py | 3 +- dwave/optimization/symbols/set_routines.pyi | 2 + dwave/optimization/symbols/set_routines.pyx | 69 +++- .../is-disjoint-cover-9f6bd97b637511fc.yaml | 11 + tests/cpp/nodes/test_set_routines.cpp | 310 ++++++++++++++++-- tests/test_generators.py | 66 ++-- tests/test_model.py | 2 + tests/test_symbols.py | 46 +++ 15 files changed, 742 insertions(+), 67 deletions(-) create mode 100644 releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml diff --git a/dwave/optimization/generators.py b/dwave/optimization/generators.py index 012479a2..2dbad3d6 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, @@ -377,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 @@ -385,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, @@ -398,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]] """ @@ -511,10 +513,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(routes)) # The objective is to minimize the distance traveled. # This is calculated by adding the distance from the depot to the 1st customer @@ -631,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 @@ -639,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, @@ -757,9 +758,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(routes)) # Capacity constraint capacity_constraints = [(demand[routes[vehicle_idx]].sum() <= capacity) diff --git a/dwave/optimization/include/dwave-optimization/nodes.hpp b/dwave/optimization/include/dwave-optimization/nodes.hpp index 52d86e04..e9e181c0 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" diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index 862f9619..af0c91bc 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -22,6 +22,45 @@ namespace dwave::optimization { +class IsDisjointCoverNode : public ScalarOutputMixin, false> { + public: + IsDisjointCoverNode(std::span node_ptrs, ssize_t primary_set_size); + + /// @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::max() + double max() const override; + + /// @copydoc Array::min() + double min() 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; + + /// @copydoc Node::revert() + void revert(State& state) const override; + + private: + ssize_t primary_set_size_; + std::vector operands_; +}; + class IsInNode : public ArrayOutputMixin> { public: IsInNode(ArrayNode* element_ptr, ArrayNode* test_elements_ptr); diff --git a/dwave/optimization/libcpp/nodes/set_routines.pxd b/dwave/optimization/libcpp/nodes/set_routines.pxd index 10bd7a1f..64d089da 100644 --- a/dwave/optimization/libcpp/nodes/set_routines.pxd +++ b/dwave/optimization/libcpp/nodes/set_routines.pxd @@ -16,5 +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 diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 08a4c59a..67b0d6e6 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -37,6 +37,7 @@ Exp, Expit, Extract, + IsDisjointCover, IsIn, LessEqual, LinearProgram, @@ -93,6 +94,7 @@ "expit", "extract", "hstack", + "is_disjoint_cover", "isin", "less_equal", "linprog", @@ -1116,6 +1118,52 @@ def hstack(arrays: collections.abc.Sequence[ArraySymbol]) -> ArraySymbol: return concatenate(arrays, 1) +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: + 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 + + 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(subsets, primary_set_size=5) + >>> with model.lock(): + ... print(is_disjoint.state(0)) + 1.0 + + See Also: + :class:`~dwave.optimization.symbols.IsDisjointCover`: Generated symbol + + :func:`.isin` + + .. versionadded:: 0.7.3 + """ + 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: """Return which values of one array symbol are in another. diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index c6c9c889..b6fa230d 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -591,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.3. 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 dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import disjoint_lists = DisjointLists(self, primary_set_size, num_disjoint_lists) diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index 8251f76b..b35c8631 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( + std::span node_ptrs, + ssize_t primary_set_size +) : + ScalarOutputMixin, false>(), 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; diff --git a/dwave/optimization/symbols/__init__.py b/dwave/optimization/symbols/__init__.py index 478bf711..3ac14617 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 IsDisjointCover, IsIn from dwave.optimization.symbols.softmax import SoftMax from dwave.optimization.symbols.sorting import ArgSort from dwave.optimization.symbols.statistics import Mean @@ -142,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 83ff3422..9fed4c72 100644 --- a/dwave/optimization/symbols/set_routines.pyi +++ b/dwave/optimization/symbols/set_routines.pyi @@ -14,4 +14,6 @@ from dwave.optimization.model import ArraySymbol as _ArraySymbol +class IsDisjointCover(_ArraySymbol): ... + class IsIn(_ArraySymbol): ... diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index bb57618e..cc283c13 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -14,10 +14,75 @@ # 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, Symbol +from dwave.optimization.libcpp cimport dynamic_cast_ptr +from dwave.optimization.libcpp.graph cimport ArrayNode +from dwave.optimization.libcpp.nodes.set_routines cimport IsDisjointCoverNode, IsInNode + + +cdef class IsDisjointCover(ArraySymbol): + """Tests whether the symbols are disjoint, set-like, and cover a set of integers + + See Also: + + .. versionadded:: 0.7.3 + """ + 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") + + 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 IsDisjointCoverNode* ptr = model._graph.emplace_node[IsDisjointCoverNode](cppinputs, n) + self.initialize_arraynode(model, ptr) + + @classmethod + def _from_symbol(cls, Symbol symbol): + cdef IsDisjointCoverNode* ptr = dynamic_cast_ptr[IsDisjointCoverNode](symbol.node_ptr) + if not ptr: + raise TypeError(f"given symbol cannot construct a {cls.__name__}") + cdef IsDisjointCover 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(list(predecessors), args["primary_set_size"]) + + 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 -from dwave.optimization._model cimport _Graph, _register, ArraySymbol -from dwave.optimization.libcpp.nodes.set_routines cimport IsInNode +_register(IsDisjointCover, typeid(IsDisjointCoverNode)) cdef class IsIn(ArraySymbol): diff --git a/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml b/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml new file mode 100644 index 00000000..c6615690 --- /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` + diff --git a/tests/cpp/nodes/test_set_routines.cpp b/tests/cpp/nodes/test_set_routines.cpp index de79611f..a60ed066 100644 --- a/tests/cpp/nodes/test_set_routines.cpp +++ b/tests/cpp/nodes/test_set_routines.cpp @@ -28,6 +28,271 @@ 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(sets, 5); + 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(sets, 6); + 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(sets, 4)); + } + } + + 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(sets, 5); + 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(sets, 5); + 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(lists, 5); + 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})); + } + } + } + } + } + + SECTION("equality") { + auto graph = Graph(); + + auto* c0_ptr = graph.emplace_node(std::vector{0, 1, 2, 3}); + auto* c1_ptr = graph.emplace_node(std::vector{4, 5, 6, 7}); + auto* c2_ptr = graph.emplace_node(std::vector{4, 5, 6, 7}); + + std::vector sets01{c0_ptr, c1_ptr}; + std::vector sets02{c0_ptr, c2_ptr}; + + Node* a_ptr = graph.emplace_node(sets01, 8); + Node* b_ptr = graph.emplace_node(sets01, 8); + Node* c_ptr = graph.emplace_node(sets02, 8); + + CHECK(a_ptr->equal_to(*a_ptr)); + CHECK(a_ptr->equal_to(*b_ptr)); + CHECK(not a_ptr->equal_to(*c0_ptr)); + CHECK(not a_ptr->equal_to(*c_ptr)); + } + + SECTION("predecessor replacement") { + auto graph = Graph(); + + auto* c0_ptr = graph.emplace_node(std::vector{0, 1, 2, 3}); + auto* c1_ptr = graph.emplace_node(std::vector{4, 5, 6, 7}); + auto* c2_ptr = graph.emplace_node(std::vector{4, 5, 6, 7}); + + std::vector sets01{c0_ptr, c1_ptr}; + + auto* dc_ptr = graph.emplace_node(sets01, 8); + + CHECK_THAT(dc_ptr->predecessors(), RangeEquals({c0_ptr, c1_ptr})); + + c2_ptr->take_successors(*c1_ptr); + + CHECK_THAT(dc_ptr->predecessors(), RangeEquals({c0_ptr, c2_ptr})); + + auto state = graph.initialize_state(); + CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + } +} + TEST_CASE("IsInNode") { auto graph = Graph(); @@ -231,40 +496,40 @@ 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); + 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})); - } + 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); + AND_WHEN("We commit, shrink the state of both set nodes, and propagate") { + graph.commit(state); - set1_ptr->shrink(state); - set2_ptr->shrink(state); + set1_ptr->shrink(state); + set2_ptr->shrink(state); - graph.propagate(state); + graph.propagate(state); - THEN("The isin's state is correct") { - CHECK(isin_ptr->view(state).size() == 0); - } + THEN("The isin's state is correct") { + CHECK(isin_ptr->view(state).size() == 0); } } } } + } SECTION("predecessor replacement") { auto* e0_ptr = graph.emplace_node(std::vector{0, 1, 6, 7}); @@ -284,4 +549,5 @@ TEST_CASE("IsInNode") { CHECK_THAT(isin_ptr->view(state), RangeEquals({0, 0, 0, 1})); } } + } // namespace dwave::optimization diff --git a/tests/test_generators.py b/tests/test_generators.py index 05cff43f..6c7cf09c 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 b265c678..a9bbee8e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -395,7 +395,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 8ea3ce41..cd53f58a 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1532,6 +1532,52 @@ def test_state_serialization_uninitialized(self): np.testing.assert_array_equal(ys[2].state(1), [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.IsDisjointCover(sets, 5) + + with model.lock(): + yield cover + + def test(self): + from dwave.optimization.symbols import IsDisjointCover + model = Model() + sets = [ + model.constant([0, 1, 2]), + model.constant([3, 4]) + ] + cover = dwave.optimization.symbols.IsDisjointCover(sets, 5) + self.assertIsInstance(cover, IsDisjointCover) + + def test_state(self): + model = Model() + # a disjoint cover + sets = [ + model.constant([0, 1, 2]), + model.constant([3, 4]) + ] + cover = dwave.optimization.symbols.IsDisjointCover(sets, 5) + 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.IsDisjointCover(sets, 5) + with model.lock(): + 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 ccdd5498a1620ecf2e2253a7d37ddba30a9e46fc Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Mon, 10 Aug 2026 20:30:37 -0400 Subject: [PATCH 30/37] Fix IsDisjointCoverNode comparison and replace predecessors --- .../dwave-optimization/nodes/set_routines.hpp | 8 +++++++- dwave/optimization/src/nodes/set_routines.cpp | 19 ++++++++++++++++++- tests/cpp/nodes/test_set_routines.cpp | 19 ++++++++++++------- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp index af0c91bc..13de318b 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/set_routines.hpp @@ -22,7 +22,8 @@ namespace dwave::optimization { -class IsDisjointCoverNode : public ScalarOutputMixin, false> { +class IsDisjointCoverNode + : public ScalarOutputMixin, false> { public: IsDisjointCoverNode(std::span node_ptrs, ssize_t primary_set_size); @@ -35,6 +36,9 @@ class IsDisjointCoverNode : public ScalarOutputMixin, f /// @copydoc Array::diff() std::span diff(const State& state) const override; + /// @copydoc Node::equal_to() + bool equal_to(const IsDisjointCoverNode& rhs) const override; + /// @copydoc Node::initialize_state() void initialize_state(State& state) const override; @@ -59,6 +63,8 @@ class IsDisjointCoverNode : public ScalarOutputMixin, f private: ssize_t primary_set_size_; std::vector operands_; + + void replace_predecessor_(ssize_t index, Node* node_ptr) override; }; class IsInNode : public ArrayOutputMixin> { diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index b35c8631..7b739a30 100644 --- a/dwave/optimization/src/nodes/set_routines.cpp +++ b/dwave/optimization/src/nodes/set_routines.cpp @@ -115,7 +115,8 @@ IsDisjointCoverNode::IsDisjointCoverNode( std::span node_ptrs, ssize_t primary_set_size ) : - ScalarOutputMixin, false>(), primary_set_size_(primary_set_size) { + ScalarOutputMixin, false>(), + 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"); @@ -190,12 +191,28 @@ std::span IsDisjointCoverNode::diff(const State& state) const { ); } +bool IsDisjointCoverNode::equal_to(const IsDisjointCoverNode& rhs) const { + // Check predecessors AND primary set size + return ( + primary_set_size_ == rhs.primary_set_size_ and // + std::ranges::equal(this->predecessors(), rhs.predecessors()) + ); +} + bool IsDisjointCoverNode::integral() const { return true; } double IsDisjointCoverNode::min() const { return 0.0; } double IsDisjointCoverNode::max() const { return 1.0; } +void IsDisjointCoverNode::replace_predecessor_(ssize_t index, Node* node_ptr) { + Node::replace_predecessor_(index, node_ptr); + + assert(0 <= index and static_cast(index) < operands_.size()); + operands_[index] = dynamic_cast(node_ptr); + assert(operands_[index] != nullptr); +} + // IsInNode ******************************************************************* struct IsInNodeSetData { IsInNodeSetData() = default; diff --git a/tests/cpp/nodes/test_set_routines.cpp b/tests/cpp/nodes/test_set_routines.cpp index a60ed066..cf222e09 100644 --- a/tests/cpp/nodes/test_set_routines.cpp +++ b/tests/cpp/nodes/test_set_routines.cpp @@ -264,11 +264,13 @@ TEST_CASE("IsDisjointCoverNode") { Node* a_ptr = graph.emplace_node(sets01, 8); Node* b_ptr = graph.emplace_node(sets01, 8); Node* c_ptr = graph.emplace_node(sets02, 8); + Node* d_ptr = graph.emplace_node(sets01, 9); CHECK(a_ptr->equal_to(*a_ptr)); CHECK(a_ptr->equal_to(*b_ptr)); CHECK(not a_ptr->equal_to(*c0_ptr)); CHECK(not a_ptr->equal_to(*c_ptr)); + CHECK(not a_ptr->equal_to(*d_ptr)); } SECTION("predecessor replacement") { @@ -276,20 +278,23 @@ TEST_CASE("IsDisjointCoverNode") { auto* c0_ptr = graph.emplace_node(std::vector{0, 1, 2, 3}); auto* c1_ptr = graph.emplace_node(std::vector{4, 5, 6, 7}); - auto* c2_ptr = graph.emplace_node(std::vector{4, 5, 6, 7}); - std::vector sets01{c0_ptr, c1_ptr}; - auto* dc_ptr = graph.emplace_node(sets01, 8); CHECK_THAT(dc_ptr->predecessors(), RangeEquals({c0_ptr, c1_ptr})); - c2_ptr->take_successors(*c1_ptr); + auto* c2_ptr = graph.emplace_node(std::vector{0, 1, 2, 4}); + auto* c3_ptr = graph.emplace_node(std::vector{3, 5, 6}); - CHECK_THAT(dc_ptr->predecessors(), RangeEquals({c0_ptr, c2_ptr})); + c2_ptr->take_successors(*c0_ptr); + c3_ptr->take_successors(*c1_ptr); - auto state = graph.initialize_state(); - CHECK_THAT(dc_ptr->view(state), RangeEquals({1.0})); + CHECK_THAT(dc_ptr->predecessors(), RangeEquals({c2_ptr, c3_ptr})); + + GIVEN("A state") { + auto state = graph.initialize_state(); + CHECK_THAT(dc_ptr->view(state), RangeEquals({0.0})); + } } } From 2b780aea684fc1cbee51fcbde011777effdd7ae4 Mon Sep 17 00:00:00 2001 From: Haseeb Rehman <196839044+hurdwave@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:54:15 -0700 Subject: [PATCH 31/37] Add const overload for DisjointBitSetsNode::get_containing_set_index() --- .../include/dwave-optimization/nodes/collections.hpp | 1 + dwave/optimization/src/nodes/collections.cpp | 4 ++++ .../const-get-containing-set-index-4ff5b13c0bd39bf6.yaml | 6 ++++++ 3 files changed, 11 insertions(+) create mode 100644 releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml diff --git a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp index 21c44ebe..28b640ba 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp @@ -117,6 +117,7 @@ class DisjointBitSetsNode : public DecisionNode { void commit(State&) const override; ssize_t get_containing_set_index(State& state, ssize_t element_i) const; + ssize_t get_containing_set_index(const State& state, ssize_t element_i) const; void initialize_state(State& state) const override; diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 71dc2239..12a9eb87 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -625,6 +625,10 @@ ssize_t DisjointBitSetsNode::get_containing_set_index(State& state, ssize_t elem return data_ptr_(state)->get_containing_set_index(element); } +ssize_t DisjointBitSetsNode::get_containing_set_index(const State& state, ssize_t element) const { + return data_ptr_(state)->get_containing_set_index(element); +} + DisjointBitSetNode::DisjointBitSetNode(DisjointBitSetsNode* disjoint_bit_sets_node) : ArrayOutputMixin(disjoint_bit_sets_node->primary_set_size()), disjoint_bit_sets_node_(disjoint_bit_sets_node), diff --git a/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml b/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml new file mode 100644 index 00000000..4cff2bfa --- /dev/null +++ b/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Add C++ ``DisjointBitSetsNode::get_containing_set_index()`` overload + accepting a ``const State&``. This allows the containing set of an element + to be queried when only a const state is available. From cfe36a0350866029071abd99a883dfef81e0b49c Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Tue, 11 Aug 2026 14:51:51 -0400 Subject: [PATCH 32/37] Revert deprecation of disjoint_lists_symbol --- dwave/optimization/generators.py | 24 +++---- dwave/optimization/model.py | 9 --- .../is-disjoint-cover-9f6bd97b637511fc.yaml | 5 -- tests/test_generators.py | 66 +++++++++---------- tests/test_model.py | 1 - tests/test_symbols.py | 1 - 6 files changed, 42 insertions(+), 64 deletions(-) diff --git a/dwave/optimization/generators.py b/dwave/optimization/generators.py index 2dbad3d6..012479a2 100644 --- a/dwave/optimization/generators.py +++ b/dwave/optimization/generators.py @@ -34,7 +34,6 @@ concatenate, exp, expit, - is_disjoint_cover, logical_or, maximum, minimum, @@ -378,7 +377,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 = list(model.iter_decisions()) + >>> routes = next(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,8 +385,7 @@ class as the decision variable being optimized, with permutations of its nonlinear :term:`solver`. >>> model.states.resize(1) - >>> routes[0].set_state(0, [2., 7., 1., 5.]) - >>> routes[1].set_state(0, [4., 3., 8., 6., 0.]) + >>> routes.set_state(0, [[2., 7., 1., 5.], [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, @@ -400,7 +398,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]}")) + ... f" {[r.state(i).tolist() for r in routes.iter_successors()]}")) 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]] """ @@ -513,8 +511,10 @@ class as the decision variable being optimized, with permutations of its demand = model.constant(customer_demand) 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(routes)) + # Add the decision variable + routes = model.disjoint_lists_symbol( + primary_set_size=num_customers, + num_disjoint_lists=number_of_vehicles) # The objective is to minimize the distance traveled. # This is calculated by adding the distance from the depot to the 1st customer @@ -631,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 = list(model.iter_decisions()) + >>> routes = next(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 @@ -639,8 +639,7 @@ class as the decision variable being optimized, with permutations of its nonlinear :term:`solver`. >>> model.states.resize(1) - >>> routes[0].set_state(0, [0, 2]) - >>> routes[1].set_state(0, [1]) + >>> routes.set_state(0, [[0, 2], [1]]) You can use the :meth:`~dwave.optimization.model.Model.iter_constraints` method to check feasibility of constructed or returned solutions. Here, @@ -758,8 +757,9 @@ class as the decision variable being optimized, with permutations of its one = model.constant(1) # Add the decision variable - routes = [model.list(num_customers, min_size=0) for _ in range(number_of_vehicles)] - model.add_constraint(is_disjoint_cover(routes)) + routes = model.disjoint_lists_symbol( + primary_set_size=num_customers, + num_disjoint_lists=number_of_vehicles) # Capacity constraint capacity_constraints = [(demand[routes[vehicle_idx]].sum() <= capacity) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index b6fa230d..c6c9c889 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -591,15 +591,6 @@ 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.3. 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 dwave.optimization.symbols import DisjointLists, DisjointList # avoid circular import disjoint_lists = DisjointLists(self, primary_set_size, num_disjoint_lists) diff --git a/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml b/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml index c6615690..d1d111b2 100644 --- a/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml +++ b/releasenotes/notes/is-disjoint-cover-9f6bd97b637511fc.yaml @@ -4,8 +4,3 @@ 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` - diff --git a/tests/test_generators.py b/tests/test_generators.py index 6c7cf09c..05cff43f 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -269,17 +269,16 @@ def test_basics(self): locations_y=[2, 0], depot_x_y=[0, 0]) - self.assertEqual(model.num_decisions(), 2) - self.assertEqual(model.num_constraints(), num_vehicles+1) + self.assertEqual(model.num_decisions(), 1) + self.assertEqual(model.num_constraints(), num_vehicles) self.assertEqual(model.num_nodes(), 34) self.assertEqual(model.num_edges(), 46) self.assertEqual(model.is_locked(), True) model.states.resize(1) - routes = list(model.iter_decisions()) - self.assertEqual(len(routes), 2) - routes[0].set_state(0, [0]) - routes[1].set_state(0, [1]) + route = next(model.iter_decisions()) + self.assertEqual(route.num_disjoint_lists(), 2) + route.set_state(0, [[0], [1]]) self.assertEqual(model.objective.state(0), 10) # Depot at (0, 1.5) from demand[0] == 0 @@ -291,10 +290,9 @@ def test_basics(self): locations_y=[1.5, 1.5]) model.states.resize(1) - routes = list(model.iter_decisions()) - self.assertEqual(len(routes), 2) - routes[0].set_state(0, [0]) - routes[1].set_state(0, []) + route = next(model.iter_decisions()) + self.assertEqual(route.num_disjoint_lists(), 2) + route.set_state(0, [[0], []]) self.assertEqual(model.objective.state(0), 6) # Depot at (3, 2) from depot_x_y @@ -307,10 +305,9 @@ def test_basics(self): depot_x_y=[3, 2]) model.states.resize(1) - routes = list(model.iter_decisions()) - self.assertEqual(len(routes), 2) - routes[0].set_state(0, [0]) - routes[1].set_state(0, [1]) + route = next(model.iter_decisions()) + self.assertEqual(route.num_disjoint_lists(), 2) + route.set_state(0, [[0], [1]]) self.assertEqual(model.objective.state(0), 10) # Test asymmetric distances @@ -321,10 +318,9 @@ def test_basics(self): distances=[[0, 1, 3], [2, 0, np.sqrt(17)], [4, np.sqrt(13), 0]]) model.states.resize(1) - routes = list(model.iter_decisions()) - self.assertEqual(len(routes), 2) - routes[0].set_state(0, [0]) - routes[1].set_state(0, [1]) + route = next(model.iter_decisions()) + self.assertEqual(route.num_disjoint_lists(), 2) + route.set_state(0, [[0], [1]]) self.assertEqual(model.objective.state(0), 10) def test_cvrplib_P_n19_k2(self): @@ -337,9 +333,9 @@ def test_cvrplib_P_n19_k2(self): vehicle_capacity=160) model.states.resize(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]]) + 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]]]) self.assertGreater(model.objective.state(0), 212) self.assertLess(model.objective.state(0), 213) @@ -370,10 +366,10 @@ def test_state_serialization(self): model.states.resize(1) - routes = list(model.iter_decisions()) + routes, = 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]]) + 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]]]) # just smoke test with model.states.to_file() as f: @@ -530,15 +526,14 @@ 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(), 2) + self.assertEqual(model.num_decisions(), 1) self.assertGreaterEqual(model.num_constraints(),min_expected_number_of_constraints) self.assertEqual(model.is_locked(), True) model.states.resize(1) - routes = list(model.iter_decisions()) - self.assertEqual(len(routes), 2) - routes[0].set_state(0, [0]) - routes[1].set_state(0, [1]) + route = next(model.iter_decisions()) + self.assertEqual(route.num_disjoint_lists(), 2) + route.set_state(0, [[0], [1]]) self.assertEqual(model.objective.state(0), 15) # Test asymmetric distances @@ -553,10 +548,9 @@ def test_basics(self): ) model.states.resize(1) - routes = list(model.iter_decisions()) - self.assertEqual(len(routes), 2) - routes[0].set_state(0, [0]) - routes[1].set_state(0, [1]) + route = next(model.iter_decisions()) + self.assertEqual(route.num_disjoint_lists(), 2) + route.set_state(0, [[0], [1]]) self.assertEqual(model.objective.state(0), 10) def test_serialization(self): @@ -606,10 +600,10 @@ def test_state_serialization(self): model.states.resize(1) - routes = list(model.iter_decisions()) + routes, = model.iter_decisions() - routes[0].set_state(0, [i-1 for i in [1, 2]]) - routes[1].set_state(0, [i-1 for i in [3]]) + routes.set_state(0, [[i-1 for i in [1, 2]], + [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 a9bbee8e..91deacbd 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -397,7 +397,6 @@ def test_remove_unused_symbols(self): 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 cd53f58a..d20926fa 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -1577,7 +1577,6 @@ 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 f28dc3981aabe8ddcdc7151b55e70a71db7e6d30 Mon Sep 17 00:00:00 2001 From: SM Harwood Date: Tue, 11 Aug 2026 17:00:52 -0400 Subject: [PATCH 33/37] Fix some code styling and doc strings --- dwave/optimization/mathematical.py | 2 +- dwave/optimization/src/nodes/set_routines.cpp | 14 +++++++------- dwave/optimization/symbols/set_routines.pyx | 2 ++ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/dwave/optimization/mathematical.py b/dwave/optimization/mathematical.py index 67b0d6e6..3dc26b30 100644 --- a/dwave/optimization/mathematical.py +++ b/dwave/optimization/mathematical.py @@ -1118,7 +1118,7 @@ def hstack(arrays: collections.abc.Sequence[ArraySymbol]) -> ArraySymbol: return concatenate(arrays, 1) -def is_disjoint_cover(subsets: list[ArraySymbol], *, primary_set_size: int|None = None) -> 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. diff --git a/dwave/optimization/src/nodes/set_routines.cpp b/dwave/optimization/src/nodes/set_routines.cpp index 7b739a30..c75b2d1e 100644 --- a/dwave/optimization/src/nodes/set_routines.cpp +++ b/dwave/optimization/src/nodes/set_routines.cpp @@ -47,7 +47,7 @@ struct IsDisjointCoverNodeData : public NodeStateData { }; public: - IsDisjointCoverNodeData(std::vector& count) : is_disjoint_cover(0, 0.0, 0.0) { + IsDisjointCoverNodeData(const 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) { @@ -118,13 +118,13 @@ IsDisjointCoverNode::IsDisjointCoverNode( ScalarOutputMixin, false>(), primary_set_size_(primary_set_size) { for (const auto& node : node_ptrs) { - if (!node->integral()) { + if (not node->integral()) { throw std::invalid_argument("Predecessors of DisjointCoverNode must be integral"); } - if (!(node->max() < primary_set_size)) { + if (not (node->max() < primary_set_size)) { throw std::invalid_argument("Predecessor exceeds primary set size"); } - if (!(node->min() >= 0)) { + if (not (node->min() >= 0)) { throw std::invalid_argument("Predecessor exceeds primary set size"); } add_predecessor_(node); @@ -143,7 +143,7 @@ void IsDisjointCoverNode::initialize_state(State& state) const { count[element] += 1; } } - emplace_data_ptr_(state, count); + emplace_data_ptr_(state, std::move(count)); } void IsDisjointCoverNode::commit(State& state) const { @@ -157,11 +157,11 @@ void IsDisjointCoverNode::propagate(State& state) const { for (const auto& pred : operands_) { for (const auto& update : pred->diff(state)) { no_update = false; - if (!update.placed()) { // i.e. removed or changed. + if (not 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. + if (not update.removed()) { // i.e. placed or changed. auto element = static_cast(update.value); data->elements_incremented.push_back(element); } diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index cc283c13..2b8ff033 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -31,6 +31,8 @@ cdef class IsDisjointCover(ArraySymbol): """Tests whether the symbols are disjoint, set-like, and cover a set of integers See Also: + :func:`~dwave.optimization.mathematical.is_disjoint_cover`: Instantiation and usage + of this symbol. .. versionadded:: 0.7.3 """ From e7fb08a87dc7304fbf6076a7939d36b62b720562 Mon Sep 17 00:00:00 2001 From: Haseeb Rehman <196839044+hurdwave@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:21:41 -0700 Subject: [PATCH 34/37] Remove DisjointBitSetsNode::get_containing_set_index() accepting a non-const State& --- .../include/dwave-optimization/nodes/collections.hpp | 1 - dwave/optimization/src/nodes/collections.cpp | 4 ---- .../const-get-containing-set-index-4ff5b13c0bd39bf6.yaml | 6 +++--- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp index 28b640ba..06f127ed 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp @@ -116,7 +116,6 @@ class DisjointBitSetsNode : public DecisionNode { void commit(State&) const override; - ssize_t get_containing_set_index(State& state, ssize_t element_i) const; ssize_t get_containing_set_index(const State& state, ssize_t element_i) const; void initialize_state(State& state) const override; diff --git a/dwave/optimization/src/nodes/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 12a9eb87..bee598ca 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -621,10 +621,6 @@ void DisjointBitSetsNode::swap_between_sets( ); } -ssize_t DisjointBitSetsNode::get_containing_set_index(State& state, ssize_t element) const { - return data_ptr_(state)->get_containing_set_index(element); -} - ssize_t DisjointBitSetsNode::get_containing_set_index(const State& state, ssize_t element) const { return data_ptr_(state)->get_containing_set_index(element); } diff --git a/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml b/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml index 4cff2bfa..f8ff40e9 100644 --- a/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml +++ b/releasenotes/notes/const-get-containing-set-index-4ff5b13c0bd39bf6.yaml @@ -1,6 +1,6 @@ --- features: - | - Add C++ ``DisjointBitSetsNode::get_containing_set_index()`` overload - accepting a ``const State&``. This allows the containing set of an element - to be queried when only a const state is available. + C++ ``DisjointBitSetsNode::get_containing_set_index()`` now accepts a + ``const State&``. This allows the containing set of an element to be + queried when only a const state is available. From 47be192094b278fdd8204da783624664c8f69cac Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Wed, 12 Aug 2026 11:29:06 -0700 Subject: [PATCH 35/37] Use PyCapsule for creating Symbols from Nodes Previously we made a malformed Symbol. This uses a default Python mechanism. --- dwave/optimization/_model.pyx | 52 ++++------ dwave/optimization/symbols/accumulate_zip.pyx | 18 ++-- dwave/optimization/symbols/collections.pyx | 95 +++++++++---------- dwave/optimization/symbols/constants.pyx | 17 ++-- dwave/optimization/symbols/creation.pyx | 14 +-- dwave/optimization/symbols/indexing.pyx | 30 +++--- dwave/optimization/symbols/inputs.pyx | 17 ++-- dwave/optimization/symbols/interpolation.pyx | 16 ++-- dwave/optimization/symbols/lp.pyx | 16 ++-- dwave/optimization/symbols/manipulation.pyx | 62 ++++++------ dwave/optimization/symbols/naryop.pyx | 32 +++---- dwave/optimization/symbols/numbers.pyx | 32 +++---- .../optimization/symbols/quadratic_model.pyx | 17 ++-- dwave/optimization/symbols/set_routines.pyx | 26 ++--- 14 files changed, 211 insertions(+), 233 deletions(-) diff --git a/dwave/optimization/_model.pyx b/dwave/optimization/_model.pyx index 28355400..57813072 100644 --- a/dwave/optimization/_model.pyx +++ b/dwave/optimization/_model.pyx @@ -26,6 +26,7 @@ import zipfile import numpy as np from cpython cimport Py_buffer +from cpython.pycapsule cimport PyCapsule_GetPointer, PyCapsule_New from cpython.ref cimport PyObject from cython.operator cimport dereference as deref, preincrement as inc from cython.operator cimport typeid @@ -91,10 +92,14 @@ cdef object symbol_from_ptr(_Graph model, cppNode* node_ptr): # IndexError would be returned by .at() raise RuntimeError("given pointer cannot be cast to a known node type") from None - # In order to get nice polymorphism, it's much easier to pass the dispatch - # through Python, so we construct a generic Symbol holding the pointer and then - # construct the specific symbol from it. - return cls._from_symbol(Symbol.from_ptr(model, node_ptr)) + # We'll use a PyCapsule to pass a node pointer through the Python layer so + # that we can let `cls` determine the type. + + # Even though PyCapsule_New returns a PyObject*, Cython automatically makes + # it an object (with appropriate refcounting) so that's nice. + cap = PyCapsule_New(node_ptr, 'Node*', NULL) + + return cls._from_ptr(model, cap) cdef class _Graph: @@ -1195,17 +1200,11 @@ cdef class Symbol: return obj @classmethod - def _from_symbol(cls, Symbol symbol): - # Disallow lateral casts or demotions. - # This is to prevent, say, an Add to be constructed from a Subtract - # There are ways around it, but this method is private anyway so it - # should be enough of a discouragement and for safety. - if not issubclass(cls, type(symbol)): - raise TypeError(f"cannot construct a {cls.__name__} from a {type(symbol).__name__}") - - cdef Symbol obj = cls.__new__(cls) - obj.initialize_node(symbol.model, symbol.node_ptr) - return obj + def _from_ptr(cls, model, capsule): + """Create a Symbol from a Python capsule containing a Node pointer.""" + cdef Symbol sym = cls.__new__(cls) + sym.initialize_node(model, (PyCapsule_GetPointer(capsule, 'Node*'))) + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -1681,23 +1680,14 @@ cdef class ArraySymbol(Symbol): self.initialize_node(model, array_ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - """Construct an ArraySymbol from another Symbol.""" - # Disallow lateral casts or demotions. - # This is to prevent, say, an Add to be constructed from a Subtract - # There are ways around it, but this method is private anyway so it - # should be enough of a discouragement and for safety. - if not issubclass(cls, type(symbol)): - raise TypeError(f"cannot construct a {cls.__name__} from a {type(symbol).__name__}") - - # Now try to "promote" the type and raise an error if that fails. - cdef cppArrayNode* ptr = dynamic_cast_ptr[cppArrayNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef ArraySymbol sym = super()._from_ptr(model, capsule) - cdef ArraySymbol obj = cls.__new__(cls) - obj.initialize_arraynode(symbol.model, ptr) - return obj + sym.array_ptr = dynamic_cast_ptr[cppArrayNode](sym.node_ptr) + if not sym.array_ptr: + raise TypeError(f"given pointer cannot construct an ArrayNode") + + return sym # Opt ArraySymbol out of default interoperability with NumPy ufuncs. We then # add explicit support with our various ____() and __r__ methods. diff --git a/dwave/optimization/symbols/accumulate_zip.pyx b/dwave/optimization/symbols/accumulate_zip.pyx index 0319a8a3..f4d84ed4 100644 --- a/dwave/optimization/symbols/accumulate_zip.pyx +++ b/dwave/optimization/symbols/accumulate_zip.pyx @@ -157,16 +157,14 @@ cdef class AccumulateZip(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef AccumulateZipNode* ptr = dynamic_cast_ptr[AccumulateZipNode]( - symbol.node_ptr - ) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef AccumulateZip x = AccumulateZip.__new__(AccumulateZip) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef AccumulateZip sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[AccumulateZipNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a AccumulateZip") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/collections.pyx b/dwave/optimization/symbols/collections.pyx index 778fdf74..3eea6d85 100644 --- a/dwave/optimization/symbols/collections.pyx +++ b/dwave/optimization/symbols/collections.pyx @@ -70,14 +70,14 @@ cdef class DisjointBitSet(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef DisjointBitSetNode* ptr = dynamic_cast_ptr[DisjointBitSetNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef DisjointBitSet x = DisjointBitSet.__new__(DisjointBitSet) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef DisjointBitSet sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[DisjointBitSetNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a DisjointBitSet") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -169,15 +169,14 @@ cdef class DisjointBitSets(Symbol): self.initialize_node(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef DisjointBitSetsNode* ptr = dynamic_cast_ptr[DisjointBitSetsNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef DisjointBitSets sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[DisjointBitSetsNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a DisjointBitSets") - cdef DisjointBitSets x = DisjointBitSets.__new__(DisjointBitSets) - x.ptr = ptr - x.initialize_node(symbol.model, ptr) - return x + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -375,14 +374,14 @@ cdef class DisjointList(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef DisjointListNode* ptr = dynamic_cast_ptr[DisjointListNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef DisjointList x = DisjointList.__new__(DisjointList) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef DisjointList sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[DisjointListNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a DisjointList") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -478,14 +477,14 @@ cdef class DisjointLists(Symbol): return DisjointList(self, index) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef DisjointListsNode* ptr = dynamic_cast_ptr[DisjointListsNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef DisjointLists x = DisjointLists.__new__(DisjointLists) - x.ptr = ptr - x.initialize_node(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef DisjointLists sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[DisjointListsNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a DisjointLists") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -682,15 +681,14 @@ cdef class ListVariable(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef ListNode* ptr = dynamic_cast_ptr[ListNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef ListVariable sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[ListNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a ListVariable") - cdef ListVariable x = ListVariable.__new__(ListVariable) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -781,15 +779,14 @@ cdef class SetVariable(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef SetNode* ptr = dynamic_cast_ptr[SetNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef SetVariable x = SetVariable.__new__(SetVariable) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef SetVariable sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[SetNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a SetVariable") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/constants.pyx b/dwave/optimization/symbols/constants.pyx index 2fe1d4f7..49e0b886 100644 --- a/dwave/optimization/symbols/constants.pyx +++ b/dwave/optimization/symbols/constants.pyx @@ -169,15 +169,14 @@ cdef class Constant(ArraySymbol): return self.ptr.size() == 1 and self.ptr.ndim() == 0 @classmethod - def _from_symbol(cls, Symbol symbol): - cdef ConstantNode* ptr = dynamic_cast_ptr[ConstantNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef Constant constant = Constant.__new__(Constant) - constant.ptr = ptr - constant.initialize_arraynode(symbol.model, ptr) - return constant + def _from_ptr(cls, model, capsule): + cdef Constant sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[ConstantNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a Constant") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/creation.pyx b/dwave/optimization/symbols/creation.pyx index 81a5c417..4b73d2b6 100644 --- a/dwave/optimization/symbols/creation.pyx +++ b/dwave/optimization/symbols/creation.pyx @@ -93,13 +93,13 @@ cdef class ARange(ArraySymbol): raise RuntimeError # shouldn't be possible @classmethod - def _from_symbol(cls, Symbol symbol): - cdef ARangeNode* ptr = dynamic_cast_ptr[ARangeNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef ARange sym = cls.__new__(cls) - sym.ptr = ptr - sym.initialize_arraynode(symbol.model, ptr) + def _from_ptr(cls, model, capsule): + cdef ARange sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[ARangeNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a ARange") + return sym @classmethod diff --git a/dwave/optimization/symbols/indexing.pyx b/dwave/optimization/symbols/indexing.pyx index 5d067d04..9ade50db 100644 --- a/dwave/optimization/symbols/indexing.pyx +++ b/dwave/optimization/symbols/indexing.pyx @@ -123,14 +123,13 @@ cdef class AdvancedIndexing(ArraySymbol): return super().__getitem__(index) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef AdvancedIndexingNode* ptr = dynamic_cast_ptr[AdvancedIndexingNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef AdvancedIndexing sym = AdvancedIndexing.__new__(AdvancedIndexing) - sym.ptr = ptr - sym.initialize_arraynode(symbol.model, ptr) + def _from_ptr(cls, model, capsule): + cdef AdvancedIndexing sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[AdvancedIndexingNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a AdvancedIndexing") + return sym @classmethod @@ -232,14 +231,13 @@ cdef class BasicIndexing(ArraySymbol): return Slice(start, stop, step) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef BasicIndexingNode* ptr = dynamic_cast_ptr[BasicIndexingNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef BasicIndexing sym = BasicIndexing.__new__(BasicIndexing) - sym.ptr = ptr - sym.initialize_arraynode(symbol.model, ptr) + def _from_ptr(cls, model, capsule): + cdef BasicIndexing sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[BasicIndexingNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a BasicIndexing") + return sym @classmethod diff --git a/dwave/optimization/symbols/inputs.pyx b/dwave/optimization/symbols/inputs.pyx index 78cf7f39..916b4ac1 100644 --- a/dwave/optimization/symbols/inputs.pyx +++ b/dwave/optimization/symbols/inputs.pyx @@ -120,15 +120,14 @@ cdef class Input(ArraySymbol): return self.ptr.max() @classmethod - def _from_symbol(cls, Symbol symbol): - cdef InputNode* ptr = dynamic_cast_ptr[InputNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef Input inp = Input.__new__(Input) - inp.ptr = ptr - inp.initialize_arraynode(symbol.model, ptr) - return inp + def _from_ptr(cls, model, capsule): + cdef Input sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[InputNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a Input") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/interpolation.pyx b/dwave/optimization/symbols/interpolation.pyx index edef6717..de0ebcec 100644 --- a/dwave/optimization/symbols/interpolation.pyx +++ b/dwave/optimization/symbols/interpolation.pyx @@ -54,14 +54,14 @@ cdef class BSpline(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef BSplineNode * ptr = dynamic_cast_ptr[BSplineNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef BSpline m = BSpline.__new__(BSpline) - m.ptr = ptr - m.initialize_arraynode(symbol.model, ptr) - return m + def _from_ptr(cls, model, capsule): + cdef BSpline sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[BSplineNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a BSpline") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/lp.pyx b/dwave/optimization/symbols/lp.pyx index b4e967a2..517f9edc 100644 --- a/dwave/optimization/symbols/lp.pyx +++ b/dwave/optimization/symbols/lp.pyx @@ -200,14 +200,14 @@ cdef class LinearProgram(Symbol): return x.array_ptr @classmethod - def _from_symbol(cls, Symbol symbol): - cdef LinearProgramNode* ptr = dynamic_cast_ptr[LinearProgramNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef LinearProgram x = LinearProgram.__new__(LinearProgram) - x.ptr = ptr - x.initialize_node(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef LinearProgram sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[LinearProgramNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a LinearProgram") + + return sym def feasible(self, Py_ssize_t index = 0): """Return True if the indexed state is a feasible solution. diff --git a/dwave/optimization/symbols/manipulation.pyx b/dwave/optimization/symbols/manipulation.pyx index 9a5ae48a..4cf81fad 100644 --- a/dwave/optimization/symbols/manipulation.pyx +++ b/dwave/optimization/symbols/manipulation.pyx @@ -119,15 +119,14 @@ cdef class Concatenate(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef ConcatenateNode* ptr = dynamic_cast_ptr[ConcatenateNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef Concatenate sym = super()._from_ptr(model, capsule) - cdef Concatenate m = Concatenate.__new__(Concatenate) - m.ptr = ptr - m.initialize_arraynode(symbol.model, ptr) - return m + sym.ptr = dynamic_cast_ptr[ConcatenateNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a Concatenate") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -228,15 +227,14 @@ cdef class Reshape(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef ReshapeNode* ptr = dynamic_cast_ptr[ReshapeNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef Reshape sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[ReshapeNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a Reshape") - cdef Reshape m = Reshape.__new__(Reshape) - m.ptr = ptr - m.initialize_arraynode(symbol.model, ptr) - return m + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -291,15 +289,14 @@ cdef class Resize(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef ResizeNode* ptr = dynamic_cast_ptr[ResizeNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef Resize sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[ResizeNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a Resize") - cdef Resize m = Resize.__new__(Resize) - m.ptr = ptr - m.initialize_arraynode(symbol.model, ptr) - return m + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -382,15 +379,14 @@ cdef class Roll(ArraySymbol): self.initialize_arraynode(array.model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef RollNode* ptr = dynamic_cast_ptr[RollNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef Roll r = Roll.__new__(Roll) - r.ptr = ptr - r.initialize_arraynode(symbol.model, ptr) - return r + def _from_ptr(cls, model, capsule): + cdef Roll sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[RollNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a Roll") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/naryop.pyx b/dwave/optimization/symbols/naryop.pyx index 344aeedd..3c88594a 100644 --- a/dwave/optimization/symbols/naryop.pyx +++ b/dwave/optimization/symbols/naryop.pyx @@ -56,14 +56,14 @@ cdef class NaryAdd(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef NaryAddNode* ptr = dynamic_cast_ptr[NaryAddNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef NaryAdd x = NaryAdd.__new__(NaryAdd) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef NaryAdd sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[NaryAddNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a NaryAdd") + + return sym def __iadd__(self, rhs): if not self.node_ptr.successors().empty(): @@ -169,14 +169,14 @@ cdef class NaryMultiply(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef NaryMultiplyNode* ptr = dynamic_cast_ptr[NaryMultiplyNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef NaryMultiply x = NaryMultiply.__new__(NaryMultiply) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef NaryMultiply sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[NaryMultiplyNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a NaryMultiply") + + return sym def __imul__(self, rhs): if not self.node_ptr.successors().empty(): diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 93a46c92..eecf8ae7 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -175,15 +175,14 @@ cdef class BinaryVariable(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef BinaryNode* ptr = dynamic_cast_ptr[BinaryNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") + def _from_ptr(cls, model, capsule): + cdef BinaryVariable sym = super()._from_ptr(model, capsule) - cdef BinaryVariable x = BinaryVariable.__new__(BinaryVariable) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + sym.ptr = dynamic_cast_ptr[BinaryNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a BinaryVariable") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): @@ -403,15 +402,14 @@ cdef class IntegerVariable(ArraySymbol): self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef IntegerNode* ptr = dynamic_cast_ptr[IntegerNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef IntegerVariable x = IntegerVariable.__new__(IntegerVariable) - x.ptr = ptr - x.initialize_arraynode(symbol.model, ptr) - return x + def _from_ptr(cls, model, capsule): + cdef IntegerVariable sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[IntegerNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a IntegerVariable") + + return sym @classmethod def _from_zipfile(cls, zf, directory, _Graph model, predecessors): diff --git a/dwave/optimization/symbols/quadratic_model.pyx b/dwave/optimization/symbols/quadratic_model.pyx index efb1c3e0..85f8d564 100644 --- a/dwave/optimization/symbols/quadratic_model.pyx +++ b/dwave/optimization/symbols/quadratic_model.pyx @@ -160,15 +160,14 @@ cdef class QuadraticModel(ArraySymbol): self._init_from_coords(x, (data, coords), ldata) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef QuadraticModelNode* ptr = dynamic_cast_ptr[QuadraticModelNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - - cdef QuadraticModel qm = QuadraticModel.__new__(QuadraticModel) - qm.ptr = ptr - qm.initialize_arraynode(symbol.model, ptr) - return qm + def _from_ptr(cls, model, capsule): + cdef QuadraticModel sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[QuadraticModelNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a QuadraticModel") + + return sym def get_linear(self, Py_ssize_t v): """Return the linear bias of a variable. diff --git a/dwave/optimization/symbols/set_routines.pyx b/dwave/optimization/symbols/set_routines.pyx index 2b8ff033..37503043 100644 --- a/dwave/optimization/symbols/set_routines.pyx +++ b/dwave/optimization/symbols/set_routines.pyx @@ -52,18 +52,17 @@ cdef class IsDisjointCover(ArraySymbol): raise ValueError("all predecessors must be from the same model") cppinputs.push_back((symbol).array_ptr) - self.primary_set_size = n - cdef IsDisjointCoverNode* ptr = model._graph.emplace_node[IsDisjointCoverNode](cppinputs, n) - self.initialize_arraynode(model, ptr) + self.ptr = model._graph.emplace_node[IsDisjointCoverNode](cppinputs, n) + self.initialize_arraynode(model, self.ptr) @classmethod - def _from_symbol(cls, Symbol symbol): - cdef IsDisjointCoverNode* ptr = dynamic_cast_ptr[IsDisjointCoverNode](symbol.node_ptr) - if not ptr: - raise TypeError(f"given symbol cannot construct a {cls.__name__}") - cdef IsDisjointCover sym = cls.__new__(cls) - sym.primary_set_size = ptr.primary_set_size() - sym.initialize_arraynode(symbol.model, ptr) + def _from_ptr(cls, model, capsule): + cdef IsDisjointCover sym = super()._from_ptr(model, capsule) + + sym.ptr = dynamic_cast_ptr[IsDisjointCoverNode](sym.node_ptr) + if not sym.ptr: + raise TypeError(f"given pointer cannot construct a IsDisjointCover") + return sym @classmethod @@ -82,7 +81,12 @@ cdef class IsDisjointCover(ArraySymbol): 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 + @property + def primary_set_size(self): + """The size of the set to be covered""" + return self.ptr.primary_set_size() + + cdef IsDisjointCoverNode* ptr _register(IsDisjointCover, typeid(IsDisjointCoverNode)) From 1fcb5bf0552fc2fa29b82ca241aa455db527b189 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 12 Aug 2026 16:08:30 -0700 Subject: [PATCH 36/37] Add additional ``BinaryNode`` checkpoint tests --- tests/cpp/nodes/test_numbers.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 0bbff69c..9312d29d 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -1719,6 +1719,35 @@ TEST_CASE("BinaryNode") { } } } + + AND_WHEN("We create a checkpoint to that state") { + auto checkpoint = bnode_ptr->checkpoint(state); + + THEN("We can mutate, then propose, and then assign from that checkpoint") { + // Index 6 falls in slice 1 along axis 0. + bnode_ptr->flip(state, 6, std::vector{1}); // 1 -> 0 + // Index 4 falls in slice 1 along axis 0. + bnode_ptr->flip(state, 4, std::vector{1}); // 0 -> 1 + // Index 11 falls in slice 2 along axis 0. + bnode_ptr->flip(state, 11, std::vector{2}); // 1 -> 0 + // state is now: [0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0] + graph.propose(state); + + bnode_ptr->assign_from_checkpoint(state, checkpoint); + THEN("Sum constraint sums and tracked indices checkpointed correctly") { + CHECK_THAT( + bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 4}) + ); + check_indices(state, bnode_ptr, 0, 0, {1}); + check_indices(state, bnode_ptr, 0, 0, {0, 2, 3}); + check_indices(state, bnode_ptr, 0, 1, {6, 7}); + check_indices(state, bnode_ptr, 0, 1, {4, 5}); + check_indices(state, bnode_ptr, 0, 2, {8, 9, 10, 11}); + check_indices(state, bnode_ptr, 0, 2, {}); + CHECK(bnode_ptr->diff(state).size() == 3); + } + } + } } } // *********************** Sum Constraint tests ************************* From 3987f1032d3254c4a2f9722f3e39407b82672505 Mon Sep 17 00:00:00 2001 From: Alexander Condello Date: Tue, 4 Aug 2026 12:45:11 -0700 Subject: [PATCH 37/37] Don't serialize the states of intermediate variables --- dwave/optimization/_model.pyx | 6 -- .../include/dwave-optimization/graph.hpp | 7 --- .../dwave-optimization/nodes/inputs.hpp | 3 - .../include/dwave-optimization/nodes/lp.hpp | 3 - dwave/optimization/libcpp/graph.pxd | 1 - dwave/optimization/src/nodes/lp.cpp | 2 - dwave/optimization/states.pyx | 50 ++++++++++------ ...deterministic-states-8c0780e8a0ab4bfe.yaml | 6 ++ tests/cpp/nodes/test_binaryop.cpp | 2 - tests/cpp/nodes/test_collections.cpp | 8 --- tests/cpp/nodes/test_numbers.cpp | 4 -- tests/test_symbols.py | 60 ------------------- tests/utils.py | 4 -- 13 files changed, 37 insertions(+), 119 deletions(-) create mode 100644 releasenotes/notes/deterministic-states-8c0780e8a0ab4bfe.yaml diff --git a/dwave/optimization/_model.pyx b/dwave/optimization/_model.pyx index 57813072..da79b04a 100644 --- a/dwave/optimization/_model.pyx +++ b/dwave/optimization/_model.pyx @@ -1134,12 +1134,6 @@ cdef class Symbol: self.node_ptr = node_ptr self.expired_ptr = node_ptr.expired_ptr() - def _deterministic_state(self): - """Return ``True`` if the symbol's state is uniquely determined by its - predecessors. - """ - return self.node_ptr.deterministic_state() - def equals(self, other): """Compare whether two symbols are identical. diff --git a/dwave/optimization/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index 8009de45..ab9a9994 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -282,10 +282,6 @@ class Node { /// Commit any changing updates to the node. virtual void commit(State& state) const = 0; - /// Return true if the node's state is deterministic - that is it's uniquely - /// derived from its predecessors. Defaults to `true`, except for decisions. - virtual bool deterministic_state() const { return true; } - /// Test whether two nodes are equal. Each node class defines equality for /// itself but nodes *must* share the same set of /// predecessors (permutations are sometimes allowed) and they *must* be the @@ -475,9 +471,6 @@ class DecisionNode : public Decision, public virtual Node { /// Get a checkpoint, an IOU that can be used to return the node to its current state. virtual checkpoint_type checkpoint(State& state) const = 0; - /// Decision nodes by definition do not have a deterministic state. - bool deterministic_state() const final { return false; } - /// Decision can only ever be equal to themselves because they are /// independent variables. bool equal_to(const Node& rhs) const final { return static_cast(this) == &rhs; } diff --git a/dwave/optimization/include/dwave-optimization/nodes/inputs.hpp b/dwave/optimization/include/dwave-optimization/nodes/inputs.hpp index 578799f6..70d610d1 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/inputs.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/inputs.hpp @@ -70,9 +70,6 @@ class InputNode : public ArrayOutputMixin { /// @copydoc Node::commit() void commit(State& state) const noexcept override; - /// InputNode's state is not deterministic unlike most other non-decision nodes - bool deterministic_state() const override { return false; } - /// @copydoc Array::diff() std::span diff(const State& state) const noexcept override; diff --git a/dwave/optimization/include/dwave-optimization/nodes/lp.hpp b/dwave/optimization/include/dwave-optimization/nodes/lp.hpp index d364d8d7..a89282ae 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/lp.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/lp.hpp @@ -131,9 +131,6 @@ class LinearProgramNode : public EqualityMixin` above. """ self.resolve() @@ -449,16 +454,23 @@ cdef class States: model = self._model() # get a ref-counted model - for symbol in model.iter_symbols(): - if symbol._deterministic_state(): - continue - + for symbol in model.iter_decisions(): symbol._states_into_zipfile( zf, num_states=num_states, version=version, ) + # If the model is not locked then the Inputs definitely won't have + # states. + if model.is_locked(): + for symbol in model.iter_inputs(): + symbol._states_into_zipfile( + zf, + num_states=num_states, + version=version, + ) + cdef _Graph _model(self): """Get a ref-counted Model object.""" cdef _Graph m = self._model_ref() diff --git a/releasenotes/notes/deterministic-states-8c0780e8a0ab4bfe.yaml b/releasenotes/notes/deterministic-states-8c0780e8a0ab4bfe.yaml new file mode 100644 index 00000000..e3ec1a50 --- /dev/null +++ b/releasenotes/notes/deterministic-states-8c0780e8a0ab4bfe.yaml @@ -0,0 +1,6 @@ +--- +upgrade: + - Don't serialize the states of intermediate symbols. + - | + Remove ``Node::deterministic_state()`` method. All intermediate nodes are + now treated as deterministic. diff --git a/tests/cpp/nodes/test_binaryop.cpp b/tests/cpp/nodes/test_binaryop.cpp index fcd9f98c..f99cc9dd 100644 --- a/tests/cpp/nodes/test_binaryop.cpp +++ b/tests/cpp/nodes/test_binaryop.cpp @@ -66,8 +66,6 @@ TEMPLATE_TEST_CASE( CHECK(static_cast(p_ptr->operands()[0]) == static_cast(a_ptr)); } - THEN("The state is deterministic") { CHECK(p_ptr->deterministic_state()); } - THEN("The shape is also a scalar") { CHECK(p_ptr->ndim() == 0); CHECK(p_ptr->size() == 1); diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index de0d6de0..ac2cae6c 100644 --- a/tests/cpp/nodes/test_collections.cpp +++ b/tests/cpp/nodes/test_collections.cpp @@ -39,8 +39,6 @@ TEST_CASE("DisjointBitSetsNode") { CHECK(ptr->num_disjoint_sets() == 3); } - THEN("The state is not deterministic") { CHECK(!ptr->deterministic_state()); } - WHEN("We add three array output successors") { std::vector sets; for (int i = 0; i < 3; ++i) { @@ -243,8 +241,6 @@ TEST_CASE("DisjointListsNode") { const ssize_t num_disjoint_lists = 3; auto ptr = graph.emplace_node(primary_set_size, num_disjoint_lists); - THEN("The state is not deterministic") { CHECK(!ptr->deterministic_state()); } - THEN("We already know a lot about the size etc") { CHECK(ptr->primary_set_size() == 5); CHECK(ptr->num_disjoint_lists() == 3); @@ -452,8 +448,6 @@ TEST_CASE("ListNode") { const int num_elements = 5; auto ptr = graph.emplace_node(num_elements); - THEN("The state is not deterministic") { CHECK(!ptr->deterministic_state()); } - THEN("We already know a lot about the size etc") { CHECK(ptr->size() == 5); CHECK_THAT(ptr->shape(), RangeEquals({5})); @@ -672,8 +666,6 @@ TEST_CASE("SetNode") { graph.emplace_node(ptr); - THEN("The state is not deterministic") { CHECK(!ptr->deterministic_state()); } - THEN("The shape is dynamic") { CHECK(ptr->ndim() == 1); CHECK(ptr->size() == Array::DYNAMIC_SIZE); diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 9312d29d..571aedfc 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -190,8 +190,6 @@ TEST_CASE("BinaryNode") { GIVEN("A Binary Node representing an 1d array of 10 elements") { auto ptr = graph.emplace_node(std::initializer_list{10}); - THEN("The state is not deterministic") { CHECK(!ptr->deterministic_state()); } - THEN("The shape is fixed") { CHECK(ptr->ndim() == 1); CHECK(ptr->size() == 10); @@ -1777,8 +1775,6 @@ TEST_CASE("IntegerNode") { ) { IntegerNode inode({1}); - THEN("The state is not deterministic") { CHECK(!inode.deterministic_state()); } - THEN("The function to check valid integers works") { CHECK(inode.max() == 2000000000); CHECK(inode.min() == 0); diff --git a/tests/test_symbols.py b/tests/test_symbols.py index d20926fa..3d42b8f9 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -2727,66 +2727,6 @@ def test_set_state(self): self.assertEqual(feas.state(), False) self.assertEqual(feas.state(), lp.feasible()) - def test_serialization_with_states(self): - # min: - # -x0 - x1 - # such that: - # x0 + x1 <= 1 - # -x0 <= 0 - # -x1 <= 0 - model = Model() - - c = model.constant([-1, -1]) - A = model.constant([[1, 1], [0, -1], [-1, 0]]) - b = model.constant([1, 0, 0]) - res = dwave.optimization.linprog(c, A=A, b_ub=b) - - lp = res.lp - - model.states.resize(4) - model.lock() - - lp._set_state(0, [0, 1]) - lp._set_state(1, [1, 0]) - # no states for 2 - lp._set_state(3, [1, 1]) - - with self.subTest("model; lock=False"): - with model.to_file(max_num_states=float("inf")) as f: - copy = Model.from_file(f) # lock=False by default - - _, _, _, lp_copy = copy.iter_symbols() - - self.assertFalse(copy.is_locked()) - with copy.lock(): - # these are all freshly calculated - self.assertEqual(lp_copy.state(0).sum(), 1) - self.assertEqual(lp_copy.state(1).sum(), 1) - self.assertEqual(lp_copy.state(2).sum(), 1) - self.assertEqual(lp_copy.state(3).sum(), 1) - - with self.subTest("model; lock=True"): - with model.to_file(max_num_states=float("inf")) as f: - copy = Model.from_file(f, lock=True) - - _, _, _, lp_copy = copy.iter_symbols() - - self.assertTrue(copy.is_locked()) - np.testing.assert_array_equal(lp_copy.state(0), [0, 1]) - np.testing.assert_array_equal(lp_copy.state(1), [1, 0]) - self.assertFalse(lp_copy.has_state(2)) - np.testing.assert_array_equal(lp_copy.state(3), [1, 1]) - - with self.subTest("states"): - with model.states.to_file() as f: - model.states.clear() - model.states.from_file(f) - - np.testing.assert_array_equal(lp.state(0), [0, 1]) - np.testing.assert_array_equal(lp.state(1), [1, 0]) - self.assertFalse(lp.has_state(2)) - np.testing.assert_array_equal(lp.state(3), [1, 1]) - def test_fallback(self): expected_c = np.asarray([1, 2, 3]) expected_A_eq = np.asarray([[4, 5, 6], [7, 8, 9]]) diff --git a/tests/utils.py b/tests/utils.py index a0a75040..68ce3ef2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -270,10 +270,6 @@ def test_broadcasting(self): with model.lock(): np.testing.assert_equal(out_arr, out_sym.state()) - def test_deterministic(self): - x = next(self.generate_symbols()) - self.assertTrue(x._deterministic_state()) - def test_info(self): for x in self.generate_symbols(): info = x.info()