diff --git a/.circleci/config.yml b/.circleci/config.yml index 08b2bc791..b66c34b26 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 diff --git a/dwave/optimization/include/dwave-optimization/array.hpp b/dwave/optimization/include/dwave-optimization/array.hpp index e94e211e6..dbfbacef5 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/include/dwave-optimization/graph.hpp b/dwave/optimization/include/dwave-optimization/graph.hpp index 3874f9f3e..8009de458 100644 --- a/dwave/optimization/include/dwave-optimization/graph.hpp +++ b/dwave/optimization/include/dwave-optimization/graph.hpp @@ -155,6 +155,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. @@ -463,6 +468,13 @@ NodeType* Graph::emplace_node(Args&&... args) { class ArrayNode : public Array, public virtual Node {}; class DecisionNode : public Decision, public virtual Node { public: + /// 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; + /// Decision nodes by definition do not have a deterministic state. bool deterministic_state() const final { return false; } diff --git a/dwave/optimization/include/dwave-optimization/nodes/collections.hpp b/dwave/optimization/include/dwave-optimization/nodes/collections.hpp index 88b573bf0..21c44ebec 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; + /// @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; + /// @copydoc DecisionNode::checkpoint() + checkpoint_type checkpoint(State& state) const override; + void commit(State&) const override; std::span diff(const State& state) const override; @@ -100,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; @@ -175,6 +189,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 3d5bc593d..ea4c18f25 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,13 @@ 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; + // 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( @@ -153,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; @@ -288,16 +303,6 @@ class IntegerNode : public NumberNode { // @copydoc NumberNode::is_valid() bool is_valid(ssize_t index, double value) const override; - // IntegerNode methods **************************************************** - - // 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 *************************************** @@ -409,33 +414,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, - 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/include/dwave-optimization/nodes/testing.hpp b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp index 33374ed83..3954ff67d 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/testing.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/testing.hpp @@ -105,6 +105,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 implement 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/include/dwave-optimization/state.hpp b/dwave/optimization/include/dwave-optimization/state.hpp index b094b8c4d..2228ddbb2 100644 --- a/dwave/optimization/include/dwave-optimization/state.hpp +++ b/dwave/optimization/include/dwave-optimization/state.hpp @@ -36,4 +36,17 @@ struct NodeStateData { using State = typename std::vector>; +/// A generic base class for node checkpoints. +struct NodeStateCheckpoint { + NodeStateCheckpoint() = default; + NodeStateCheckpoint(const NodeStateCheckpoint&) = delete; + NodeStateCheckpoint(NodeStateCheckpoint&&) = delete; + NodeStateCheckpoint& operator=(const NodeStateCheckpoint&) = delete; + NodeStateCheckpoint& operator=(NodeStateCheckpoint&&) = delete; + + virtual ~NodeStateCheckpoint() = default; +}; + +using checkpoint_type = std::unique_ptr; + } // namespace dwave::optimization diff --git a/dwave/optimization/src/graph.cpp b/dwave/optimization/src/graph.cpp index 02bc3aec9..50473f9a3 100644 --- a/dwave/optimization/src/graph.cpp +++ b/dwave/optimization/src/graph.cpp @@ -230,6 +230,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/_checkpoints.cpp b/dwave/optimization/src/nodes/_checkpoints.cpp new file mode 100644 index 000000000..c45bbfe80 --- /dev/null +++ b/dwave/optimization/src/nodes/_checkpoints.cpp @@ -0,0 +1,91 @@ +// 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 { + +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) { + 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_ + ); +} + +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_)) { + prev_ptr->commit_updates(std::vector(diff.begin(), diff.end())); + assert(prev_ptr->drop_ == 0); + } +} + +DiffCheckpoint::~DiffCheckpoint() { + // 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) { + 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 new file mode 100644 index 000000000..af39e978f --- /dev/null +++ b/dwave/optimization/src/nodes/_checkpoints.hpp @@ -0,0 +1,130 @@ +// 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 +#include + +#include "dwave-optimization/array.hpp" +#include "dwave-optimization/state.hpp" + +namespace dwave::optimization { + +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; + + // 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; + CheckpointableState& operator=(CheckpointableState&&) = default; + + ~CheckpointableState(); + + protected: + template T> + T* checkpoint_ptr() { + return static_cast(prev_ptr_); + } + template T> + const T* checkpoint_ptr() const { + return static_cast(prev_ptr_); + } + + 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. + // 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_; +}; + +} // namespace dwave::optimization diff --git a/dwave/optimization/src/nodes/_state.hpp b/dwave/optimization/src/nodes/_state.hpp index 1e5db77b7..0ba4e597c 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 @@ -149,6 +165,29 @@ class ArrayStateData { size_ = buffer.size(); } + // 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_); + 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 ::revert(). + // 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/collections.cpp b/dwave/optimization/src/nodes/collections.cpp index 45d0e94f1..71dc2239e 100644 --- a/dwave/optimization/src/nodes/collections.cpp +++ b/dwave/optimization/src/nodes/collections.cpp @@ -14,10 +14,13 @@ #include "dwave-optimization/nodes/collections.hpp" +#include #include #include #include +#include "_checkpoints.hpp" + namespace dwave::optimization { // Given a collection, check that it is a valid sub-permutation of range(n), @@ -66,7 +69,19 @@ std::vector augment_collection_(std::vector values, const ssize_ return values; } -class CollectionStateData_ : public NodeStateData { +class CollectionStateData_; + +class CollectionCheckpoint_ : public DiffCheckpoint { + public: + CollectionCheckpoint_(CollectionStateData_& state); + + ssize_t size() const { return size_; } + + private: + ssize_t size_; +}; + +class CollectionStateData_ : public NodeStateData, public CheckpointableState { public: explicit CollectionStateData_(ssize_t n) : CollectionStateData_(n, n) {} @@ -109,11 +124,68 @@ 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(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()); + + // now do the mutation + for (const auto& [idx, old, _] : excess) { + set_(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 + // if we need to + while (size_ > checkpoint_ptr->size()) shrink(); + + for (const auto& [idx, old, _] : checkpoint_ptr->detach_updates() | std::views::reverse) { + set_(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 (auto* checkpoint_ptr = this->checkpoint_ptr()) { + checkpoint_ptr->commit_updates(std::move(all_updates_)); + assert(all_updates_.empty()); + } else { + all_updates_.clear(); + } + previous_size_ = size_; } @@ -150,16 +222,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 (auto* checkpoint_ptr = this->checkpoint_ptr()) { + checkpoint_ptr->revert_updates(std::move(all_updates_)); + assert(all_updates_.empty()); + } else { + all_updates_.clear(); + } + size_ = previous_size_; } @@ -199,6 +277,18 @@ class CollectionStateData_ : public NodeStateData { 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 std::vector elements_; @@ -215,6 +305,9 @@ class CollectionStateData_ : public NodeStateData { ssize_t previous_size_; }; +CollectionCheckpoint_::CollectionCheckpoint_(CollectionStateData_& state) : + 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), max_value_(max_value), @@ -242,6 +335,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(); } @@ -329,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); @@ -378,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]; @@ -445,6 +581,27 @@ void DisjointBitSetsNode::initialize_state( ); } +void DisjointBitSetsNode::assign_from_checkpoint( + State& state, + std::unique_ptr& checkpoint +) const { + const auto* 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(); } @@ -503,7 +660,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); @@ -733,6 +903,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/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index da03f2d74..85855051a 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,17 +73,98 @@ 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_updates() { + auto updates = DiffCheckpoint::detach_updates(); + + using join_type = decltype(std::move(slice_caches_) | std::views::join); + + 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::make_tuple(std::move(updates), 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)) {} + 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_); + } std::unique_ptr copy() const override { return std::make_unique(*this); @@ -90,23 +172,86 @@ struct NumberNodeStateData : public ArrayNodeStateData { /// 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()); + } + + 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(); } /// Revert the state dependent data of NumberNode. void revert(); - /// 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); - /// 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 - ); + 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); + } + } + } + } /// 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 @@ -123,6 +268,20 @@ struct NumberNodeStateData : public ArrayNodeStateData { /// 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_; + + 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() { @@ -142,68 +301,124 @@ void NumberNodeStateData::revert() { sum_constraints_lhs[j][slices[j]] -= difference; } } - slice_cache_.clear(); // Empty the slice cache. } - ArrayNodeStateData::revert(); // Revert changes to the buffer. -} - -void NumberNodeStateData::update( - const NumberNode& node, - 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. + 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. } - slice_cache_.emplace_back(std::move(cache_entry)); // Cache the slices. + + // everything should have been cleared out regardless of which path we took + assert(this->diff().empty()); + assert(slice_cache_.empty()); } -void NumberNodeStateData::update( - const NumberNode& node, +void NumberNodeStateData::update_( const ssize_t index, const double difference, - std::vector slices + std::optional> optional_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()); - 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. + } +} + +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); + } } - slice_cache_.emplace_back(std::move(slices)); // Cache the slices. + + 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 { @@ -320,7 +535,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. @@ -331,7 +546,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) ); } } @@ -529,6 +744,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(); } @@ -544,34 +763,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(*this, 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); - } 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); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(*this, 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 { @@ -620,20 +812,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(*this, index, value - diff(state).back().old, *slices); - } else { - state_data->update(*this, 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 { @@ -960,31 +1150,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(*this, index, value - diff(state).back().old, *slices); - } else { - state_data->update(*this, 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); } @@ -1260,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); } @@ -1278,23 +1445,21 @@ struct BinaryNodeStateData : public NumberNodeStateData { /// Revert the state dependent data of BinaryNode. void revert(); - /// 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); - /// 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 - ); - /// 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_( + ssize_t index, + double difference, + std::optional> optional_slices = std::nullopt + ) override; }; void BinaryNodeStateData::revert() { @@ -1329,75 +1494,71 @@ void BinaryNodeStateData::revert() { ArrayNodeStateData::revert(); // Revert changes to the buffer. } -void BinaryNodeStateData::update( - const BinaryNode& node, - 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 BinaryNode& node, +void BinaryNodeStateData::update_( const ssize_t index, const double difference, - std::vector slices + std::optional> optional_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()); 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(([&]() { + + // 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 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 + // `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. - 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]); + 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. } - slice_cache_.emplace_back(std::move(slices)); // Cache the slices. } void BinaryNodeStateData::compute_slice_indices_(const BinaryNode& node) { @@ -1451,7 +1612,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. @@ -1462,8 +1623,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)); } } @@ -1486,91 +1646,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(*this, 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); - } 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); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - state_data->update(*this, j, -difference); - } - } - } -} - -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(*this, index, value - diff(state).back().old, *slices); - } else { - state_data->update(*this, 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(*this, index, value - diff(state).back().old, *slices); - } else { - state_data->update(*this, index, value - diff(state).back().old); - } - } - } -} - void BinaryNode::flip( State& state, ssize_t index, @@ -1579,20 +1654,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(*this, index, (state_data->get(index) == 1) ? 1 : -1, *slices); - } else { - state_data->update(*this, 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( diff --git a/meson.build b/meson.build index 690d71652..b8882ec82 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', diff --git a/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml b/releasenotes/notes/feature-checkpointing-b770d2f2b66f648d.yaml new file mode 100644 index 000000000..014fda90a --- /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 all decision nodes. + See `#510 `_. diff --git a/tests/cpp/nodes/test_collections.cpp b/tests/cpp/nodes/test_collections.cpp index 61fff1429..de0d6de0f 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" @@ -173,6 +173,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") { @@ -344,6 +385,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") { @@ -725,6 +812,324 @@ 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})); + } + } + } + } + + AND_WHEN("We destruct the state before the checkpoint") { + 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 + } + + 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") { + set_ptr->assign(state, {4, 1}); + auto checkpoint = set_ptr->checkpoint(state); + + AND_WHEN("We do a sequence of commits") { + 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") { + 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})); + } + + 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(); + + // 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(); + } + } + } + } + } } } // namespace dwave::optimization diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 575db125c..0bbff69c2 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; @@ -294,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, 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); + CHECK_THAT(ptr->view(state), RangeEquals({0, 1, 0, 1, 0, 1, 0, 1, 0, 1})); + } + } } } @@ -1892,6 +1905,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 +2000,55 @@ 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})); + } + + 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") { + 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 +2259,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 +2275,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})); + } + } + } } }