From 0aa46bdec29e761d8bd794851107c874c1461bee Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 6 Jan 2026 09:31:39 -0800 Subject: [PATCH 01/22] Add stateless axis-wise bound info to NumberNode Data is stored at C++ level with the class `AxisBoundInfo` as private attribute to `NumberNode`. Added relevant C++ tests. --- .../dwave-optimization/nodes/numbers.hpp | 127 ++++-- dwave/optimization/src/nodes/numbers.cpp | 366 +++++++++++++----- tests/cpp/nodes/test_numbers.cpp | 144 +++++++ 3 files changed, 501 insertions(+), 136 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 59c93db1..9760b27f 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -25,6 +25,36 @@ namespace dwave::optimization { +/// Allowable axis-wise bound operators. +enum BoundAxisOperator { Equal, LessEqual, GreaterEqual }; + +/// Class for stateless axis-wise bound information. Given an `axis`, define +/// constraints on the sum of the values in each slice along `axis`. +/// Constraints can be defined for ALL slices along `axis` or PER slice along +/// `axis`. Allowable operators are defined by `BoundAxisOperator`. +class BoundAxisInfo { + public: + /// To reduce the # of `IntegerNode` and `BinaryNode` constructors, we + /// allow only one constructor. + BoundAxisInfo(ssize_t axis, std::vector axis_operators, + std::vector axis_bounds); + /// The bound axis + const ssize_t axis; + /// Operator for ALL axis slices (vector has length one) or operator*s* PER + /// slice (length of vector is equal to the number of slices). + const std::vector operators; + /// Bound for ALL axis slices (vector has length one) or bound*s* PER slice + /// (length of vector is equal to the number of slices). + const std::vector bounds; + + private: + /// Obtain the bound associated with a given slice along bound axis. + double get_bound(const ssize_t slice) const; + + /// Obtain the operator associated with a given slice along bound axis. + BoundAxisOperator get_operator(const ssize_t slice) const; +}; + /// A contiguous block of numbers. class NumberNode : public ArrayOutputMixin, public DecisionNode { public: @@ -106,9 +136,16 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // in a given index. void clip_and_set_value(State& state, ssize_t index, double value) const; + /// The number of axes with axis-wise bounds. + ssize_t num_bound_axes() const; + + /// Return the bound information for the ith bound axis + const BoundAxisInfo* get_ith_bound_axis_info(const ssize_t i) const; + protected: explicit NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound); + std::vector upper_bound, + std::optional> bound_axes = std::nullopt); // Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; @@ -119,8 +156,12 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { double min_; double max_; + // Stateless index-wise upper and lower bounds std::vector lower_bounds_; std::vector upper_bounds_; + + /// Stateless information on each bound axis. + const std::vector bound_axes_info_; }; /// A contiguous block of integer numbers. @@ -134,33 +175,45 @@ class IntegerNode : public NumberNode { // Default to a single scalar integer with default bounds IntegerNode() : IntegerNode({}) {} - // Create an integer array with the user-defined bounds. - // Defaulting to the specified default bounds. + // Create an integer array with the user-defined index- and axis-wise bounds. + // Index-wise bounds default to the specified default bounds. IntegerNode(std::span shape, std::optional> lower_bound = std::nullopt, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); IntegerNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); IntegerNode(ssize_t size, std::optional> lower_bound = std::nullopt, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); IntegerNode(std::span shape, double lower_bound, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); IntegerNode(std::initializer_list shape, double lower_bound, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); IntegerNode(ssize_t size, double lower_bound, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); IntegerNode(std::span shape, std::optional> lower_bound, - double upper_bound); + double upper_bound, + std::optional> bound_axes = std::nullopt); IntegerNode(std::initializer_list shape, - std::optional> lower_bound, double upper_bound); - IntegerNode(ssize_t size, std::optional> lower_bound, double upper_bound); - - IntegerNode(std::span shape, double lower_bound, double upper_bound); - IntegerNode(std::initializer_list shape, double lower_bound, double upper_bound); - IntegerNode(ssize_t size, double lower_bound, double upper_bound); + std::optional> lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + IntegerNode(ssize_t size, std::optional> lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + + IntegerNode(std::span shape, double lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + IntegerNode(std::initializer_list shape, double lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + IntegerNode(ssize_t size, double lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); // Overloads needed by the Node ABC *************************************** @@ -190,33 +243,45 @@ class BinaryNode : public IntegerNode { /// A binary scalar variable with lower_bound = 0.0 and upper_bound = 1.0 BinaryNode() : BinaryNode({}) {} - // Create a binary array with the user-defined bounds. - // Defaulting to lower_bound = 0.0 and upper_bound = 1.0 + // Create a binary array with the user-defined index- and axis-wise bounds. + // Index-wise bounds default to lower_bound = 0.0 and upper_bound = 1.0. BinaryNode(std::span shape, std::optional> lower_bound = std::nullopt, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); BinaryNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); BinaryNode(ssize_t size, std::optional> lower_bound = std::nullopt, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); BinaryNode(std::span shape, double lower_bound, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); BinaryNode(std::initializer_list shape, double lower_bound, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); BinaryNode(ssize_t size, double lower_bound, - std::optional> upper_bound = std::nullopt); + std::optional> upper_bound = std::nullopt, + std::optional> bound_axes = std::nullopt); BinaryNode(std::span shape, std::optional> lower_bound, - double upper_bound); + double upper_bound, + std::optional> bound_axes = std::nullopt); BinaryNode(std::initializer_list shape, std::optional> lower_bound, - double upper_bound); - BinaryNode(ssize_t size, std::optional> lower_bound, double upper_bound); - - BinaryNode(std::span shape, double lower_bound, double upper_bound); - BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound); - BinaryNode(ssize_t size, double lower_bound, double upper_bound); + double upper_bound, + std::optional> bound_axes = std::nullopt); + BinaryNode(ssize_t size, std::optional> lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + + BinaryNode(std::span shape, double lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); + BinaryNode(ssize_t size, double lower_bound, double upper_bound, + std::optional> bound_axes = std::nullopt); // Flip the value (0 -> 1 or 1 -> 0) at index i in the given state. void flip(State& state, ssize_t i) const; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 5ad26c99..2e6be87c 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -23,7 +23,168 @@ namespace dwave::optimization { +BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector axis_operators, + std::vector axis_bounds) + : axis(bound_axis), operators(std::move(axis_operators)), bounds(std::move(axis_bounds)) { + const ssize_t num_operators = operators.size(); + const ssize_t num_bounds = bounds.size(); + + // Null `operators` and `bounds` are not accepted. + if ((num_operators == 0) || (num_bounds == 0)) { + throw std::invalid_argument("Bad axis-wise bounds for axis: " + std::to_string(axis) + + ", `operators` and `bounds` must each have non-zero size."); + } + + // If `operators` and `bounds` are defined PER hyperslice along `axis`, + // they must have the same size. + if ((num_operators > 1) && (num_bounds > 1) && (num_bounds != num_operators)) { + throw std::invalid_argument( + "Bad axis-wise bounds for axis: " + std::to_string(axis) + + ", `operators` and `bounds` should have same size if neither has size 1."); + } +} + +double BoundAxisInfo::get_bound(const ssize_t slice) const { + const ssize_t max_slice = bounds.size(); + // Negative indexing is not supported. + if ((slice < 0) || (slice >= max_slice)) { + throw std::invalid_argument("Out of range slice: " + std::to_string(slice) + + " along axis: " + std::to_string(axis)); + } + + if (max_slice == 1) { + return bounds[0]; + } + return bounds[slice]; +} + +BoundAxisOperator BoundAxisInfo::get_operator(const ssize_t slice) const { + const ssize_t max_slice = operators.size(); + // Negative indexing is not supported. + if ((slice < 0) || (slice >= max_slice)) { + throw std::invalid_argument("Out of range slice: " + std::to_string(slice) + + " along axis: " + std::to_string(axis)); + } + + if (max_slice == 1) { + return operators[0]; + } + return operators[slice]; +} + +template +double get_extreme_index_wise_bound(const std::vector& bound) { + assert(bound.size() > 0); + std::vector::const_iterator it; + if (maximum) { + it = std::max_element(bound.begin(), bound.end()); + } else { + it = std::min_element(bound.begin(), bound.end()); + } + return *it; +} + +void check_index_wise_bounds(const NumberNode& node, const std::vector& lower_bounds_, + const std::vector& upper_bounds_) { + bool index_wise_bound = false; + // If lower bound is index-wise, it must be correct size. + if (lower_bounds_.size() > 1) { + index_wise_bound = true; + if (static_cast(lower_bounds_.size()) != node.size()) { + throw std::invalid_argument("lower_bound must match size of node"); + } + } + // If upper bound is index-wise, it must be correct size. + if (upper_bounds_.size() > 1) { + index_wise_bound = true; + if (static_cast(upper_bounds_.size()) != node.size()) { + throw std::invalid_argument("upper_bound must match size of node"); + } + } + // If at least one of the bounds is index-wise, check that there are no + // violations at any of the indices. + if (index_wise_bound) { + for (ssize_t i = 0, stop = node.size(); i < stop; ++i) { + if (node.lower_bound(i) > node.upper_bound(i)) { + throw std::invalid_argument("Bounds of index " + std::to_string(i) + " clash"); + } + } + } +} + +/// Check the user defined axis-wise bounds for NumberNode +void check_axis_wise_bounds(const std::vector& bound_axes_info, + const std::span shape) { + if (bound_axes_info.size() == 0) { // No bound axes to check. + return; + } + + // Used to asses if an axis have been bound multiple times. + std::vector axis_bound(shape.size(), false); + + // For each set of bound axis data + for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { + const ssize_t axis = bound_axis_info.axis; + + if (axis < 0 || axis >= shape.size()) { + throw std::invalid_argument( + "Invalid bound axis: " + std::to_string(axis) + + ". Note, negative indexing is not supported for axis-wise bounds."); + } + + // The number of operators defined for the given bound axis + const ssize_t num_operators = bound_axis_info.operators.size(); + if ((num_operators > 1) && (num_operators != shape[axis])) { + throw std::invalid_argument( + "Invalid number of axis-wise operators along axis: " + std::to_string(axis) + + " given axis shape: " + std::to_string(shape[axis])); + } + + // The number of operators defined for the given bound axis + const ssize_t num_bounds = bound_axis_info.bounds.size(); + if ((num_bounds > 1) && (num_bounds != shape[axis])) { + throw std::invalid_argument( + "Invalid number of axis-wise bounds along axis: " + std::to_string(axis) + + " given axis shape: " + std::to_string(shape[axis])); + } + + // Checked in BoundAxisInfo constructor + assert(num_operators == num_bounds || num_operators == 1 || num_bounds == 1); + + if (axis_bound[axis]) { + throw std::invalid_argument( + "Cannot define multiple axis-wise bounds for a single axis."); + } + axis_bound[axis] = true; + } + + // *Currently*, we only support axis-wise bounds for up to one axis. + if (bound_axes_info.size() > 1) { + throw std::invalid_argument("Axis-wise bounds are supported for at most one axis."); + } +} + // Base class to be used as interfaces. +NumberNode::NumberNode(std::span shape, std::vector lower_bound, + std::vector upper_bound, + std::optional> bound_axes) + : ArrayOutputMixin(shape), + min_(get_extreme_index_wise_bound(lower_bound)), + max_(get_extreme_index_wise_bound(upper_bound)), + lower_bounds_(std::move(lower_bound)), + upper_bounds_(std::move(upper_bound)), + bound_axes_info_(bound_axes ? std::move(*bound_axes) : std::vector{}) { + if ((shape.size() > 0) && (shape[0] < 0)) { + throw std::invalid_argument("Number array cannot have dynamic size."); + } + + if (max_ < min_) { + throw std::invalid_argument("Invalid range for number array provided."); + } + + check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); + check_axis_wise_bounds(bound_axes_info_, this->shape()); +} double const* NumberNode::buff(const State& state) const noexcept { return data_ptr(state)->buff(); @@ -124,74 +285,29 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c data_ptr(state)->set(index, value); } -template -double get_extreme_index_wise_bound(const std::vector& bound) { - assert(bound.size() > 0); - std::vector::const_iterator it; - if (maximum) { - it = std::max_element(bound.begin(), bound.end()); - } else { - it = std::min_element(bound.begin(), bound.end()); - } - return *it; -} +ssize_t NumberNode::num_bound_axes() const { + return static_cast(bound_axes_info_.size()); +}; -void check_index_wise_bounds(const NumberNode& node, const std::vector& lower_bounds_, - const std::vector& upper_bounds_) { - bool index_wise_bound = false; - // If lower bound is index-wise, it must be correct size. - if (lower_bounds_.size() > 1) { - index_wise_bound = true; - if (static_cast(lower_bounds_.size()) != node.size()) { - throw std::invalid_argument("lower_bound must match size of node"); - } +const BoundAxisInfo* NumberNode::get_ith_bound_axis_info(const ssize_t i) const { + if (i < 0 || i >= bound_axes_info_.size()) { + throw std::invalid_argument("Invalid ith bound axis requested: " + std::to_string(i)); } - // If upper bound is index-wise, it must be correct size. - if (upper_bounds_.size() > 1) { - index_wise_bound = true; - if (static_cast(upper_bounds_.size()) != node.size()) { - throw std::invalid_argument("upper_bound must match size of node"); - } - } - // If at least one of the bounds is index-wise, check that there are no - // violations at any of the indices. - if (index_wise_bound) { - for (ssize_t i = 0, stop = node.size(); i < stop; ++i) { - if (node.lower_bound(i) > node.upper_bound(i)) { - throw std::invalid_argument("Bounds of index " + std::to_string(i) + " clash"); - } - } - } -} - -NumberNode::NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound) - : ArrayOutputMixin(shape), - min_(get_extreme_index_wise_bound(lower_bound)), - max_(get_extreme_index_wise_bound(upper_bound)), - lower_bounds_(std::move(lower_bound)), - upper_bounds_(std::move(upper_bound)) { - if ((shape.size() > 0) && (shape[0] < 0)) { - throw std::invalid_argument("Number array cannot have dynamic size."); - } - - if (max_ < min_) { - throw std::invalid_argument("Invalid range for number array provided."); - } - - check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); -} + return &bound_axes_info_[i]; +}; // Integer Node *************************************************************** IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, - std::optional> upper_bound) + std::optional> upper_bound, + std::optional> bound_axes) : NumberNode(shape, lower_bound.has_value() ? std::move(*lower_bound) : std::vector{default_lower_bound}, upper_bound.has_value() ? std::move(*upper_bound) - : std::vector{default_upper_bound}) { + : std::vector{default_upper_bound}, + std::move(bound_axes)) { if (min_ < minimum_lower_bound || max_ > maximum_upper_bound) { throw std::invalid_argument("range provided for integers exceeds supported range"); } @@ -199,40 +315,59 @@ IntegerNode::IntegerNode(std::span shape, IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, - std::optional> upper_bound) - : IntegerNode(std::span(shape), std::move(lower_bound), std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : IntegerNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), + std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, - std::optional> upper_bound) - : IntegerNode({size}, std::move(lower_bound), std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : IntegerNode({size}, std::move(lower_bound), std::move(upper_bound), + std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, - std::optional> upper_bound) - : IntegerNode(shape, std::vector{lower_bound}, std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : IntegerNode(shape, std::vector{lower_bound}, std::move(upper_bound), + std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, - std::optional> upper_bound) - : IntegerNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : IntegerNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), + std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, - std::optional> upper_bound) - : IntegerNode({size}, std::vector{lower_bound}, std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : IntegerNode({size}, std::vector{lower_bound}, std::move(upper_bound), + std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, - std::optional> lower_bound, double upper_bound) - : IntegerNode(shape, std::move(lower_bound), std::vector{upper_bound}) {} + std::optional> lower_bound, double upper_bound, + std::optional> bound_axes) + : IntegerNode(shape, std::move(lower_bound), std::vector{upper_bound}, + std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, - std::optional> lower_bound, double upper_bound) - : IntegerNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}) {} + std::optional> lower_bound, double upper_bound, + std::optional> bound_axes) + : IntegerNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, + std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, - double upper_bound) - : IntegerNode({size}, std::move(lower_bound), std::vector{upper_bound}) {} - -IntegerNode::IntegerNode(std::span shape, double lower_bound, double upper_bound) - : IntegerNode(shape, std::vector{lower_bound}, std::vector{upper_bound}) {} + double upper_bound, std::optional> bound_axes) + : IntegerNode({size}, std::move(lower_bound), std::vector{upper_bound}, + std::move(bound_axes)) {} + +IntegerNode::IntegerNode(std::span shape, double lower_bound, double upper_bound, + std::optional> bound_axes) + : IntegerNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, + std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, - double upper_bound) + double upper_bound, std::optional> bound_axes) : IntegerNode(std::span(shape), std::vector{lower_bound}, - std::vector{upper_bound}) {} -IntegerNode::IntegerNode(ssize_t size, double lower_bound, double upper_bound) - : IntegerNode({size}, std::vector{lower_bound}, std::vector{upper_bound}) {} + std::vector{upper_bound}, std::move(bound_axes)) {} +IntegerNode::IntegerNode(ssize_t size, double lower_bound, double upper_bound, + std::optional> bound_axes) + : IntegerNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, + std::move(bound_axes)) {} bool IntegerNode::integral() const { return true; } @@ -287,45 +422,66 @@ std::vector limit_bound_to_bool_domain(std::optional BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, - std::optional> upper_bound) + std::optional> upper_bound, + std::optional> bound_axes) : IntegerNode(shape, limit_bound_to_bool_domain(lower_bound), - limit_bound_to_bool_domain(upper_bound)) {} + limit_bound_to_bool_domain(upper_bound), bound_axes) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, - std::optional> upper_bound) - : BinaryNode(std::span(shape), std::move(lower_bound), std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : BinaryNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), + std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, - std::optional> upper_bound) - : BinaryNode({size}, std::move(lower_bound), std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : BinaryNode({size}, std::move(lower_bound), std::move(upper_bound), + std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, - std::optional> upper_bound) - : BinaryNode(shape, std::vector{lower_bound}, std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : BinaryNode(shape, std::vector{lower_bound}, std::move(upper_bound), + std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, - std::optional> upper_bound) - : BinaryNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : BinaryNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), + std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, - std::optional> upper_bound) - : BinaryNode({size}, std::vector{lower_bound}, std::move(upper_bound)) {} + std::optional> upper_bound, + std::optional> bound_axes) + : BinaryNode({size}, std::vector{lower_bound}, std::move(upper_bound), + std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, - std::optional> lower_bound, double upper_bound) - : BinaryNode(shape, std::move(lower_bound), std::vector{upper_bound}) {} + std::optional> lower_bound, double upper_bound, + std::optional> bound_axes) + : BinaryNode(shape, std::move(lower_bound), std::vector{upper_bound}, + std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, - std::optional> lower_bound, double upper_bound) - : BinaryNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}) {} + std::optional> lower_bound, double upper_bound, + std::optional> bound_axes) + : BinaryNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, + std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, - double upper_bound) - : BinaryNode({size}, std::move(lower_bound), std::vector{upper_bound}) {} - -BinaryNode::BinaryNode(std::span shape, double lower_bound, double upper_bound) - : BinaryNode(shape, std::vector{lower_bound}, std::vector{upper_bound}) {} -BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound) + double upper_bound, std::optional> bound_axes) + : BinaryNode({size}, std::move(lower_bound), std::vector{upper_bound}, + std::move(bound_axes)) {} + +BinaryNode::BinaryNode(std::span shape, double lower_bound, double upper_bound, + std::optional> bound_axes) + : BinaryNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, + std::move(bound_axes)) {} +BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, + std::optional> bound_axes) : BinaryNode(std::span(shape), std::vector{lower_bound}, - std::vector{upper_bound}) {} -BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound) - : BinaryNode({size}, std::vector{lower_bound}, std::vector{upper_bound}) {} + std::vector{upper_bound}, std::move(bound_axes)) {} +BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound, + std::optional> bound_axes) + : BinaryNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, + std::move(bound_axes)) {} void BinaryNode::flip(State& state, ssize_t i) const { auto ptr = data_ptr(state); diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 0761c74e..0458cf3c 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -25,6 +25,50 @@ using Catch::Matchers::RangeEquals; namespace dwave::optimization { +TEST_CASE("BoundAxisInfo") { + GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { + REQUIRE_THROWS_WITH( + BoundAxisInfo(0, std::vector{}, std::vector{1.0}), + "Bad axis-wise bounds for axis: 0, `operators` and `bounds` must each have " + "non-zero size."); + } + + GIVEN("BoundAxisInfo(axis = 0, operators = {<=}, bounds = {})") { + REQUIRE_THROWS_WITH( + BoundAxisInfo(0, std::vector{LessEqual}, std::vector{}), + "Bad axis-wise bounds for axis: 0, `operators` and `bounds` must each have " + "non-zero size."); + } + + GIVEN("BoundAxisInfo(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { + REQUIRE_THROWS_WITH( + BoundAxisInfo(1, std::vector{LessEqual, Equal, Equal}, + std::vector{2.0, 1.0}), + "Bad axis-wise bounds for axis: 1, `operators` and `bounds` should have same size " + "if neither has size 1."); + } + + GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0})") { + BoundAxisInfo bound_axis(2, std::vector{Equal}, + std::vector{1.0}); + THEN("The bound axis info is correct") { + CHECK(bound_axis.axis == 2); + CHECK_THAT(bound_axis.operators, RangeEquals({Equal})); + CHECK_THAT(bound_axis.bounds, RangeEquals({1.0})); + } + } + + GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { + BoundAxisInfo bound_axis(2, std::vector{Equal, LessEqual, GreaterEqual}, + std::vector{1.0, 2.0, 3.0}); + THEN("The bound axis info is correct") { + CHECK(bound_axis.axis == 2); + CHECK_THAT(bound_axis.operators, RangeEquals({Equal, LessEqual, GreaterEqual})); + CHECK_THAT(bound_axis.bounds, RangeEquals({1.0, 2.0, 3.0})); + } + } +} + TEST_CASE("BinaryNode") { auto graph = Graph(); @@ -439,6 +483,106 @@ TEST_CASE("BinaryNode") { REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{-1, 2}), "Number array cannot have dynamic size."); } + + GIVEN("(2x3)-Binary node with axis-wise bounds on the invalid axis -1") { + BoundAxisInfo bound_axis{-1, std::vector{Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid bound axis: -1. Note, negative indexing is not supported for " + "axis-wise bounds."); + } + + GIVEN("(2x3)-Binary node with axis-wise bounds on the invalid axis 2") { + BoundAxisInfo bound_axis{2, std::vector{Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid bound axis: 2. Note, negative indexing is not supported for " + "axis-wise bounds."); + } + + GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too many operators.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal, Equal, Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise operators along axis: 1 given axis shape: 3"); + } + + GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too few operators.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise operators along axis: 1 given axis shape: 3"); + } + + GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too many bounds.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual}, + std::vector{1.0, 2.0, 3.0, 4.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis shape: 3"); + } + + GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too few bounds.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual}, + std::vector{1.0, 2.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis shape: 3"); + } + + GIVEN("(2x3)-Binary node with duplicate axis-wise bounds on axis: 1") { + BoundAxisInfo bound_axis{1, std::vector{Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis, bound_axis}), + "Cannot define multiple axis-wise bounds for a single axis."); + } + + GIVEN("(2x3)-Binary node with axis-wise bounds on axes: 0 and 1") { + BoundAxisInfo bound_axis_0{0, std::vector{LessEqual}, + std::vector{1.0}}; + BoundAxisInfo bound_axis_1{1, std::vector{LessEqual}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis_0, bound_axis_1}), + "Axis-wise bounds are supported for at most one axis."); + } + + GIVEN("(2x3x4)-Binary node with an axis-wise bound on axis: 1") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual}, + std::vector{1.0, 1.0, 0.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}); + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->num_bound_axes() == 1.0); + const BoundAxisInfo* bnode_bound_axis_ptr = bnode_ptr->get_ith_bound_axis_info(0); + CHECK(bound_axis.axis == bnode_bound_axis_ptr->axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis_ptr->operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis_ptr->bounds)); + CHECK_THROWS_WITH(bnode_ptr->get_ith_bound_axis_info(1), + "Invalid ith bound axis requested: 1"); + CHECK_THROWS_WITH(bnode_ptr->get_ith_bound_axis_info(-1), + "Invalid ith bound axis requested: -1"); + } + } } TEST_CASE("IntegerNode") { From e9bd92fd0fca751e8a3ecccc525febdda2b16deb Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 6 Jan 2026 13:06:03 -0800 Subject: [PATCH 02/22] Add axis-wise bound state dependant data to NumberNode For each bound axis and each hyperslice along said axis, we store the running sum of the values within the hyperslice. This state dependant data is stored via `NumberNodeStateData`. If `NumberNode` is initialized with values, we check that all axis-wise bounds are satisfied. --- .../dwave-optimization/nodes/numbers.hpp | 12 +- dwave/optimization/src/nodes/numbers.cpp | 247 +++++++++++++++--- tests/cpp/nodes/test_numbers.cpp | 45 +++- 3 files changed, 266 insertions(+), 38 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 9760b27f..9e7a95f8 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -47,7 +47,6 @@ class BoundAxisInfo { /// (length of vector is equal to the number of slices). const std::vector bounds; - private: /// Obtain the bound associated with a given slice along bound axis. double get_bound(const ssize_t slice) const; @@ -140,7 +139,16 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { ssize_t num_bound_axes() const; /// Return the bound information for the ith bound axis - const BoundAxisInfo* get_ith_bound_axis_info(const ssize_t i) const; + const BoundAxisInfo* bound_axis_info(const ssize_t axis) const; + + /// The number of hyperslice along the ith bound axis + ssize_t num_hyperslice_along_bound_axis(State& state, const ssize_t axis) const; + + /// Get the sum of the values in the given slice along the ith bound axis + double bound_axis_hyperslice_sum(State& state, const ssize_t axis, const ssize_t slice) const; + + /// Check whether the axis-wise bounds are satisfied + bool satisfies_axis_wise_bounds(State& state) const; protected: explicit NumberNode(std::span shape, std::vector lower_bound, diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 2e6be87c..e3ebce2f 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -16,10 +16,13 @@ #include #include +#include #include #include +#include #include "_state.hpp" +#include "dwave-optimization/array.hpp" namespace dwave::optimization { @@ -45,30 +48,16 @@ BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector } double BoundAxisInfo::get_bound(const ssize_t slice) const { - const ssize_t max_slice = bounds.size(); - // Negative indexing is not supported. - if ((slice < 0) || (slice >= max_slice)) { - throw std::invalid_argument("Out of range slice: " + std::to_string(slice) + - " along axis: " + std::to_string(axis)); - } - - if (max_slice == 1) { - return bounds[0]; - } + assert(0 <= slice); + if (bounds.size() == 1) return bounds[0]; + assert(slice < bounds.size()); return bounds[slice]; } BoundAxisOperator BoundAxisInfo::get_operator(const ssize_t slice) const { - const ssize_t max_slice = operators.size(); - // Negative indexing is not supported. - if ((slice < 0) || (slice >= max_slice)) { - throw std::invalid_argument("Out of range slice: " + std::to_string(slice) + - " along axis: " + std::to_string(axis)); - } - - if (max_slice == 1) { - return operators[0]; - } + assert(0 <= slice); + if (operators.size() == 1) return operators[0]; + assert(slice < operators.size()); return operators[slice]; } @@ -112,6 +101,118 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& } } +struct NumberNodeDataHelper_ { + NumberNodeDataHelper_(std::vector input, const std::span& shape, + const std::span& strides, + const std::vector& bound_axes_info) + : values(std::move(input)) { + if (bound_axes_info.empty()) return; // No axis sums to compute. + compute_bound_axis_hyperslice_sums(shape, strides, bound_axes_info); + } + + /// Variable assignment to NumberNode + std::vector values; + /// For each bound axis and for each hyperslice along said axis, we track + /// the sum of the values within the hyperslice. + /// bound_axes_sums[i][j] = "sum of the values within the jth hyperslice along + /// the ith bound axis" + std::vector> bound_axes_sums; + + /// Determine the sum of the values of each hyperslice along each bound + /// axis given the variable assignment of NumberNode. + void compute_bound_axis_hyperslice_sums(const std::span& shape, + const std::span& strides, + const std::vector& bound_axes_info) { + const ssize_t num_bound_axes = bound_axes_info.size(); + bound_axes_sums.reserve(num_bound_axes); + + // For each variable assignment of NumberNode (stored in values), we + // need to add the variables value to the running sum for each + // hyperslice it is contained in (and that we are tracking). For each + // such variable i and each bound axis j, we can identify which + // hyperslice i lies in along j via `unravel_index(i, shape)[j]`. + // However, this is inefficient. Instead we track the running + // multidimensional index (for each bound axis we care about) and + // adjust it based on the strides of the NumberNode array as + // we iterate over the variable assignments of the NumberNode. + // + // To do this easily, we first compute the element strides from the + // byte strides of the NumberNode array. Formally + // element_strides[i] = "# of elements need get to the next hyperslice + // along the ith bound axis" + const ssize_t bytes_per_element = static_cast(sizeof(double)); + std::vector element_strides; + element_strides.reserve(num_bound_axes); + // A running stride counter for each bound axis. + // When remaining_axis_strides[i] = 0, we have moved to the next + // hyperslice along the ith bound axis. + std::vector remaining_axis_strides; + remaining_axis_strides.reserve(num_bound_axes); + + // For each bound axis + for (ssize_t i = 0; i < num_bound_axes; ++i) { + const ssize_t bound_axis = bound_axes_info[i].axis; + assert(0 <= bound_axis && bound_axis < shape.size()); + + const ssize_t num_axis_slices = shape[bound_axis]; + // Initialize the sums for each hyperslice along the bound axis. + bound_axes_sums.emplace_back(std::vector(num_axis_slices, 0.0)); + + // Update element stride data + assert(strides[bound_axis] % bytes_per_element == 0); + element_strides.emplace_back(strides[bound_axis] / bytes_per_element); + // Initialize by the total # of element_strides along the bound axis + remaining_axis_strides.push_back(element_strides[i]); + } + + // Running hyperslice index per bound axis + std::vector hyperslice_index(num_bound_axes, 0); + + // Iterate over variable assignments of NumberNode. + for (ssize_t i = 0, stop = static_cast(values.size()); i < stop; ++i) { + // Iterate over the bound axes. + for (ssize_t j = 0; j < num_bound_axes; ++j) { + const ssize_t bound_axis = bound_axes_info[j].axis; + // Check the computation of the hyperslice + assert(unravel_index(i, shape)[bound_axis] == hyperslice_index[j]); + // Accumulate sum in hyperslice along jth bound axis + bound_axes_sums[j][hyperslice_index[j]] += values[i]; + + // Update running multidimensional index + if (--remaining_axis_strides[j] == 0) { + // Moved to next hyperslice, reset `remaining_axis_strides` + remaining_axis_strides[j] = element_strides[j]; + + // Increment the multi_index along bound axis modulo the # + // of hyperslice along said axis + if (++hyperslice_index[j] == shape[bound_axis]) { + hyperslice_index[j] = 0; + } + } + } + } + } +}; + +// State dependant data attached to NumberNode + +struct NumberNodeStateData : public ArrayNodeStateData { + NumberNodeStateData(std::vector input, const std::span& shape, + const std::span& strides, + const std::vector& bound_axis_info) + : NumberNodeStateData( + NumberNodeDataHelper_(std::move(input), shape, strides, bound_axis_info)) {} + + NumberNodeStateData(NumberNodeDataHelper_&& helper) + : ArrayNodeStateData(std::move(helper.values)), + bound_axes_sums(helper.bound_axes_sums), + prior_bound_axes_sums(std::move(helper.bound_axes_sums)) {} + + std::vector> bound_axes_sums; + // Store a copy for NumberNode::revert() + std::vector> prior_bound_axes_sums; +}; + /// Check the user defined axis-wise bounds for NumberNode void check_axis_wise_bounds(const std::vector& bound_axes_info, const std::span shape) { @@ -187,11 +288,11 @@ NumberNode::NumberNode(std::span shape, std::vector lower } double const* NumberNode::buff(const State& state) const noexcept { - return data_ptr(state)->buff(); + return data_ptr(state)->buff(); } std::span NumberNode::diff(const State& state) const noexcept { - return data_ptr(state)->diff(); + return data_ptr(state)->diff(); } double NumberNode::min() const { return min_; } @@ -208,7 +309,12 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } } - emplace_data_ptr(state, std::move(number_data)); + emplace_data_ptr(state, std::move(number_data), this->shape(), + this->strides(), this->bound_axes_info_); + + if (!this->satisfies_axis_wise_bounds(state)) { + throw std::invalid_argument("Initialized values do not satisfy axis-wise bounds."); + } } void NumberNode::initialize_state(State& state) const { @@ -221,11 +327,17 @@ void NumberNode::initialize_state(State& state) const { } void NumberNode::commit(State& state) const noexcept { - data_ptr(state)->commit(); + auto node_data = data_ptr(state); + node_data->commit(); + // Manually store a copy of axis_sums + node_data->prior_bound_axes_sums = node_data->bound_axes_sums; } void NumberNode::revert(State& state) const noexcept { - data_ptr(state)->revert(); + auto node_data = data_ptr(state); + node_data->revert(); + // Manually reset axis_sums + node_data->bound_axes_sums = node_data->prior_bound_axes_sums; } void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { @@ -289,13 +401,88 @@ ssize_t NumberNode::num_bound_axes() const { return static_cast(bound_axes_info_.size()); }; -const BoundAxisInfo* NumberNode::get_ith_bound_axis_info(const ssize_t i) const { - if (i < 0 || i >= bound_axes_info_.size()) { - throw std::invalid_argument("Invalid ith bound axis requested: " + std::to_string(i)); - } - return &bound_axes_info_[i]; +const BoundAxisInfo* NumberNode::bound_axis_info(const ssize_t axis) const { + assert(axis >= 0 && axis < bound_axes_info_.size()); + return &bound_axes_info_[axis]; }; +ssize_t NumberNode::num_hyperslice_along_bound_axis(State& state, const ssize_t axis) const { + assert(axis >= 0 && axis < data_ptr(state)->bound_axes_sums.size()); + return data_ptr(state)->bound_axes_sums[axis].size(); +} + +double NumberNode::bound_axis_hyperslice_sum(State& state, const ssize_t axis, + const ssize_t slice) const { + assert(axis >= 0 && slice >= 0); + assert(axis < data_ptr(state)->bound_axes_sums.size()); + assert(slice < data_ptr(state)->bound_axes_sums[axis].size()); + return data_ptr(state)->bound_axes_sums[axis][slice]; +} + +// /// Check whether the axis-wise bound is satisfied for the given hyperslice +// void check_hyperslice(const BoundAxisInfo& bound_axis_info, const ssize_t slice, +// const double slice_sum) { +// const double rhs_bound = bound_axis_info.get_bound(slice); +// std::cout << slice_sum; +// +// switch (bound_axis_info.get_operator(slice)) { +// case Equal: +// std::cout << " == " << rhs_bound << std::endl; +// if (slice_sum == rhs_bound) return; +// case LessEqual: +// std::cout << " <= " << rhs_bound << std::endl; +// if (slice_sum <= rhs_bound) return; +// case GreaterEqual: +// std::cout << " >= " << rhs_bound << std::endl; +// if (slice_sum >= rhs_bound) return; +// default: +// throw std::invalid_argument("Invalid axis-wise bound operator"); +// } +// +// throw std::invalid_argument("Initialized state does not satisfy axis-wise bounds."); +// } + +bool NumberNode::satisfies_axis_wise_bounds(State& state) const { + const ssize_t num_bound_axes = this->num_bound_axes(); + if (num_bound_axes == 0) return true; // No bounds to satisfy + + // Grab the hyperslice sums of all bound axes + const std::vector>& bound_axes_sums = + data_ptr(state)->bound_axes_sums; + assert(num_bound_axes == bound_axes_sums.size()); + + for (ssize_t bound_axis = 0; bound_axis < num_bound_axes; ++bound_axis) { + // Grab the stateless axis-wise bound data for the bound axis + const BoundAxisInfo& bound_axis_info = this->bound_axes_info_[bound_axis]; + + // Grab the sums of all hyperslices along the bound axis + const std::vector& bound_axis_sums = bound_axes_sums[bound_axis]; + + // For each hyperslice along said axis + for (ssize_t slice = 0, stop = bound_axis_sums.size(); slice < stop; ++slice) { + const double rhs_bound = bound_axis_info.get_bound(slice); + const double slice_sum = bound_axis_sums[slice]; + + // Check whether the axis-wise bound is satisfied for the given hyperslice + switch (bound_axis_info.get_operator(slice)) { + case Equal: + if (slice_sum != rhs_bound) return false; + continue; + case LessEqual: + if (slice_sum > rhs_bound) return false; + continue; + case GreaterEqual: + if (slice_sum < rhs_bound) return false; + continue; + default: + throw std::invalid_argument("Invalid axis-wise bound operator"); + } + } + } + + return true; +} + // Integer Node *************************************************************** IntegerNode::IntegerNode(std::span shape, diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 0458cf3c..d0bf20f5 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -565,22 +565,55 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3x4)-Binary node with an axis-wise bound on axis: 1") { + auto graph = Graph(); + BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{1.0, 1.0, 0.0}}; + std::vector{4.0, 4.0, 6.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, std::vector{bound_axis}); + THEN("Axis wise bound is correct") { CHECK(bnode_ptr->num_bound_axes() == 1.0); - const BoundAxisInfo* bnode_bound_axis_ptr = bnode_ptr->get_ith_bound_axis_info(0); + const BoundAxisInfo* bnode_bound_axis_ptr = bnode_ptr->bound_axis_info(0); CHECK(bound_axis.axis == bnode_bound_axis_ptr->axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis_ptr->operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis_ptr->bounds)); - CHECK_THROWS_WITH(bnode_ptr->get_ith_bound_axis_info(1), - "Invalid ith bound axis requested: 1"); - CHECK_THROWS_WITH(bnode_ptr->get_ith_bound_axis_info(-1), - "Invalid ith bound axis requested: -1"); + } + + WHEN("We initialize an invalid state") { + auto state = graph.empty_state(); + std::vector init_values(2 * 3 * 4, 1); + // import numpy as np + // a = np.ones((2,3,4)) + // a.sum(axis=(0, 2)) + // array([8, 8, 8]) + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + + WHEN("We initialize a state") { + auto state = graph.empty_state(); + std::vector init_values{ + 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, + }; + std::cout << "here" << std::endl; + bnode_ptr->initialize_state(state, init_values); + graph.initialize_state(state); + + // import numpy as np + // a = np.array([1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, + // ... 1, 0, 1, 1, 1, 1, 0]) + // a = a.reshape(2,3,4) + // a.sum(axis=(0, 2)) + // array([3, 4, 5]) + THEN("The sums of each hyperslice along axis 1 are correct") { + CHECK(bnode_ptr->num_hyperslice_along_bound_axis(state, 0) == 3); + CHECK(bnode_ptr->bound_axis_hyperslice_sum(state, 0, 0) == 3); + CHECK(bnode_ptr->bound_axis_hyperslice_sum(state, 0, 1) == 4); + CHECK(bnode_ptr->bound_axis_hyperslice_sum(state, 0, 2) == 5); + } } } } From bec2cb2373b01e0860caecc692f1ae5795fa995a Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 12 Jan 2026 12:58:56 -0800 Subject: [PATCH 03/22] Add NumberNode axis-wise bound methods Added satisfies_axis_wise_bounds(), update_bound_axis_slice_sums(), axis_wise_bounds(), and bound_axis_sums() to NumberNode. Updated various NumberNode, IntegerNode, and BinaryNode methods to reference NumberNodeStateData as opposed to ArrayNodeStateData. Updated all NumberNode mutate methods to reflect changes to the axis-wise bound running sums. Added C++ tests to check said mutate methods on BinaryNode and IntegerNode. --- .../dwave-optimization/nodes/numbers.hpp | 31 +- dwave/optimization/src/nodes/numbers.cpp | 490 ++++++++-------- tests/cpp/nodes/test_numbers.cpp | 551 ++++++++++++++++-- 3 files changed, 784 insertions(+), 288 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 9e7a95f8..29993d90 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -135,36 +135,35 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // in a given index. void clip_and_set_value(State& state, ssize_t index, double value) const; - /// The number of axes with axis-wise bounds. - ssize_t num_bound_axes() const; + /// Return pointer to the vector of axis-wise bounds + const std::vector& axis_wise_bounds() const; - /// Return the bound information for the ith bound axis - const BoundAxisInfo* bound_axis_info(const ssize_t axis) const; - - /// The number of hyperslice along the ith bound axis - ssize_t num_hyperslice_along_bound_axis(State& state, const ssize_t axis) const; - - /// Get the sum of the values in the given slice along the ith bound axis - double bound_axis_hyperslice_sum(State& state, const ssize_t axis, const ssize_t slice) const; - - /// Check whether the axis-wise bounds are satisfied - bool satisfies_axis_wise_bounds(State& state) const; + // Return a pointer to the vector containing the bound axis sums + const std::vector>& bound_axis_sums(State& state) const; protected: explicit NumberNode(std::span shape, std::vector lower_bound, std::vector upper_bound, std::optional> bound_axes = std::nullopt); - // Return truth statement: 'value is valid in a given index'. + /// Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; - // Default value in a given index. + /// Default value in a given index. virtual double default_value(ssize_t index) const = 0; + /// Check whether all axis-wise bounds are satisfied + bool satisfies_axis_wise_bounds(State& state) const; + + /// Update the running bound axis sums where `index` is changed by + /// `value_change` in a given state. + void update_bound_axis_slice_sums(State& state, const ssize_t index, + const double value_change) const; + double min_; double max_; - // Stateless index-wise upper and lower bounds + /// Stateless index-wise upper and lower bounds std::vector lower_bounds_; std::vector upper_bounds_; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index e3ebce2f..9fad6545 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -29,8 +29,8 @@ namespace dwave::optimization { BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector axis_operators, std::vector axis_bounds) : axis(bound_axis), operators(std::move(axis_operators)), bounds(std::move(axis_bounds)) { - const ssize_t num_operators = operators.size(); - const ssize_t num_bounds = bounds.size(); + const ssize_t num_operators = static_cast(operators.size()); + const ssize_t num_bounds = static_cast(bounds.size()); // Null `operators` and `bounds` are not accepted. if ((num_operators == 0) || (num_bounds == 0)) { @@ -49,65 +49,26 @@ BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector double BoundAxisInfo::get_bound(const ssize_t slice) const { assert(0 <= slice); - if (bounds.size() == 1) return bounds[0]; - assert(slice < bounds.size()); + if (bounds.size() == 0) return bounds[0]; + assert(slice < static_cast(bounds.size())); return bounds[slice]; } BoundAxisOperator BoundAxisInfo::get_operator(const ssize_t slice) const { assert(0 <= slice); - if (operators.size() == 1) return operators[0]; - assert(slice < operators.size()); + if (operators.size() == 0) return operators[0]; + assert(slice < static_cast(operators.size())); return operators[slice]; } -template -double get_extreme_index_wise_bound(const std::vector& bound) { - assert(bound.size() > 0); - std::vector::const_iterator it; - if (maximum) { - it = std::max_element(bound.begin(), bound.end()); - } else { - it = std::min_element(bound.begin(), bound.end()); - } - return *it; -} - -void check_index_wise_bounds(const NumberNode& node, const std::vector& lower_bounds_, - const std::vector& upper_bounds_) { - bool index_wise_bound = false; - // If lower bound is index-wise, it must be correct size. - if (lower_bounds_.size() > 1) { - index_wise_bound = true; - if (static_cast(lower_bounds_.size()) != node.size()) { - throw std::invalid_argument("lower_bound must match size of node"); - } - } - // If upper bound is index-wise, it must be correct size. - if (upper_bounds_.size() > 1) { - index_wise_bound = true; - if (static_cast(upper_bounds_.size()) != node.size()) { - throw std::invalid_argument("upper_bound must match size of node"); - } - } - // If at least one of the bounds is index-wise, check that there are no - // violations at any of the indices. - if (index_wise_bound) { - for (ssize_t i = 0, stop = node.size(); i < stop; ++i) { - if (node.lower_bound(i) > node.upper_bound(i)) { - throw std::invalid_argument("Bounds of index " + std::to_string(i) + " clash"); - } - } - } -} - struct NumberNodeDataHelper_ { - NumberNodeDataHelper_(std::vector input, const std::span& shape, - const std::span& strides, - const std::vector& bound_axes_info) + NumberNodeDataHelper_(std::vector input, + const std::vector& bound_axes_info, + const std::span& shape, + const std::span& strides) : values(std::move(input)) { if (bound_axes_info.empty()) return; // No axis sums to compute. - compute_bound_axis_hyperslice_sums(shape, strides, bound_axes_info); + compute_bound_axis_hyperslice_sums(bound_axes_info, shape, strides); } /// Variable assignment to NumberNode @@ -120,24 +81,23 @@ struct NumberNodeDataHelper_ { /// Determine the sum of the values of each hyperslice along each bound /// axis given the variable assignment of NumberNode. - void compute_bound_axis_hyperslice_sums(const std::span& shape, - const std::span& strides, - const std::vector& bound_axes_info) { - const ssize_t num_bound_axes = bound_axes_info.size(); + void compute_bound_axis_hyperslice_sums(const std::vector& bound_axes_info, + const std::span& shape, + const std::span& strides) { + const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); bound_axes_sums.reserve(num_bound_axes); - // For each variable assignment of NumberNode (stored in values), we - // need to add the variables value to the running sum for each - // hyperslice it is contained in (and that we are tracking). For each - // such variable i and each bound axis j, we can identify which - // hyperslice i lies in along j via `unravel_index(i, shape)[j]`. - // However, this is inefficient. Instead we track the running - // multidimensional index (for each bound axis we care about) and - // adjust it based on the strides of the NumberNode array as - // we iterate over the variable assignments of the NumberNode. + // For each variable assignment to NumberNode (stored in `values`), we need + // to add the variables value to the running sum for each hyperslice it is + // contained in (and that we are tracking). For each such variable i and + // each bound axis j, we can identify which hyperslice i lies in along j + // via `unravel_index(i, shape)[j]`. However, this is inefficient. Instead + // we track the running multidimensional index (for each bound axis we care + // about) and adjust it based on the strides of the NumberNode array as we + // iterate over the variable assignments of the NumberNode. // - // To do this easily, we first compute the element strides from the - // byte strides of the NumberNode array. Formally + // To do this easily, we first compute the element strides from the byte + // strides of the NumberNode array. Formally // element_strides[i] = "# of elements need get to the next hyperslice // along the ith bound axis" const ssize_t bytes_per_element = static_cast(sizeof(double)); @@ -148,11 +108,14 @@ struct NumberNodeDataHelper_ { // hyperslice along the ith bound axis. std::vector remaining_axis_strides; remaining_axis_strides.reserve(num_bound_axes); + // Running hyperslice index per bound axis + std::vector hyperslice_index; + hyperslice_index.reserve(num_bound_axes); // For each bound axis for (ssize_t i = 0; i < num_bound_axes; ++i) { const ssize_t bound_axis = bound_axes_info[i].axis; - assert(0 <= bound_axis && bound_axis < shape.size()); + assert(0 <= bound_axis && bound_axis < static_cast(shape.size())); const ssize_t num_axis_slices = shape[bound_axis]; // Initialize the sums for each hyperslice along the bound axis. @@ -163,10 +126,10 @@ struct NumberNodeDataHelper_ { element_strides.emplace_back(strides[bound_axis] / bytes_per_element); // Initialize by the total # of element_strides along the bound axis remaining_axis_strides.push_back(element_strides[i]); - } - // Running hyperslice index per bound axis - std::vector hyperslice_index(num_bound_axes, 0); + // Initialize hyperslice index to 0 + hyperslice_index.emplace_back(0); + } // Iterate over variable assignments of NumberNode. for (ssize_t i = 0, stop = static_cast(values.size()); i < stop; ++i) { @@ -194,14 +157,14 @@ struct NumberNodeDataHelper_ { } }; -// State dependant data attached to NumberNode - +/// State dependant data attached to NumberNode struct NumberNodeStateData : public ArrayNodeStateData { - NumberNodeStateData(std::vector input, const std::span& shape, - const std::span& strides, - const std::vector& bound_axis_info) + NumberNodeStateData(std::vector input, + const std::vector& bound_axes_info, + const std::span& shape, + const std::span& strides) : NumberNodeStateData( - NumberNodeDataHelper_(std::move(input), shape, strides, bound_axis_info)) {} + NumberNodeDataHelper_(std::move(input), bound_axes_info, shape, strides)) {} NumberNodeStateData(NumberNodeDataHelper_&& helper) : ArrayNodeStateData(std::move(helper.values)), @@ -209,84 +172,10 @@ struct NumberNodeStateData : public ArrayNodeStateData { prior_bound_axes_sums(std::move(helper.bound_axes_sums)) {} std::vector> bound_axes_sums; - // Store a copy for NumberNode::revert() + // Store a copy for NumberNode::revert() and commit() std::vector> prior_bound_axes_sums; }; -/// Check the user defined axis-wise bounds for NumberNode -void check_axis_wise_bounds(const std::vector& bound_axes_info, - const std::span shape) { - if (bound_axes_info.size() == 0) { // No bound axes to check. - return; - } - - // Used to asses if an axis have been bound multiple times. - std::vector axis_bound(shape.size(), false); - - // For each set of bound axis data - for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { - const ssize_t axis = bound_axis_info.axis; - - if (axis < 0 || axis >= shape.size()) { - throw std::invalid_argument( - "Invalid bound axis: " + std::to_string(axis) + - ". Note, negative indexing is not supported for axis-wise bounds."); - } - - // The number of operators defined for the given bound axis - const ssize_t num_operators = bound_axis_info.operators.size(); - if ((num_operators > 1) && (num_operators != shape[axis])) { - throw std::invalid_argument( - "Invalid number of axis-wise operators along axis: " + std::to_string(axis) + - " given axis shape: " + std::to_string(shape[axis])); - } - - // The number of operators defined for the given bound axis - const ssize_t num_bounds = bound_axis_info.bounds.size(); - if ((num_bounds > 1) && (num_bounds != shape[axis])) { - throw std::invalid_argument( - "Invalid number of axis-wise bounds along axis: " + std::to_string(axis) + - " given axis shape: " + std::to_string(shape[axis])); - } - - // Checked in BoundAxisInfo constructor - assert(num_operators == num_bounds || num_operators == 1 || num_bounds == 1); - - if (axis_bound[axis]) { - throw std::invalid_argument( - "Cannot define multiple axis-wise bounds for a single axis."); - } - axis_bound[axis] = true; - } - - // *Currently*, we only support axis-wise bounds for up to one axis. - if (bound_axes_info.size() > 1) { - throw std::invalid_argument("Axis-wise bounds are supported for at most one axis."); - } -} - -// Base class to be used as interfaces. -NumberNode::NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound, - std::optional> bound_axes) - : ArrayOutputMixin(shape), - min_(get_extreme_index_wise_bound(lower_bound)), - max_(get_extreme_index_wise_bound(upper_bound)), - lower_bounds_(std::move(lower_bound)), - upper_bounds_(std::move(upper_bound)), - bound_axes_info_(bound_axes ? std::move(*bound_axes) : std::vector{}) { - if ((shape.size() > 0) && (shape[0] < 0)) { - throw std::invalid_argument("Number array cannot have dynamic size."); - } - - if (max_ < min_) { - throw std::invalid_argument("Invalid range for number array provided."); - } - - check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); - check_axis_wise_bounds(bound_axes_info_, this->shape()); -} - double const* NumberNode::buff(const State& state) const noexcept { return data_ptr(state)->buff(); } @@ -309,8 +198,8 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } } - emplace_data_ptr(state, std::move(number_data), this->shape(), - this->strides(), this->bound_axes_info_); + emplace_data_ptr(state, std::move(number_data), bound_axes_info_, + this->shape(), this->strides()); if (!this->satisfies_axis_wise_bounds(state)) { throw std::invalid_argument("Initialized values do not satisfy axis-wise bounds."); @@ -328,16 +217,16 @@ void NumberNode::initialize_state(State& state) const { void NumberNode::commit(State& state) const noexcept { auto node_data = data_ptr(state); - node_data->commit(); - // Manually store a copy of axis_sums + // Manually store a copy of bound_axes_sums. node_data->prior_bound_axes_sums = node_data->bound_axes_sums; + node_data->commit(); } void NumberNode::revert(State& state) const noexcept { auto node_data = data_ptr(state); - node_data->revert(); - // Manually reset axis_sums + // Manually reset bound_axes_sums. node_data->bound_axes_sums = node_data->prior_bound_axes_sums; + node_data->revert(); } void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { @@ -349,18 +238,26 @@ void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { assert(upper_bound(j) >= ptr->get(i)); // Assert that i and j are valid indices occurs in ptr->exchange(). // Exchange occurs IFF (i != j) and (buffer[i] != buffer[j]). - ptr->exchange(i, j); + if (ptr->exchange(i, j)) { + // If the values at indices i and j were exchanged, update the bound + // axis sums. + const double difference = ptr->get(i) - ptr->get(j); + // Index i changed from (what is now) ptr->get(j) to ptr->get(i) + update_bound_axis_slice_sums(state, i, difference); + // Index j changed from (what is now) ptr->get(i) to ptr->get(j) + update_bound_axis_slice_sums(state, j, -difference); + assert(satisfies_axis_wise_bounds(state)); + } } double NumberNode::get_value(State& state, ssize_t i) const { - return data_ptr(state)->get(i); + return data_ptr(state)->get(i); } double NumberNode::lower_bound(ssize_t index) const { if (lower_bounds_.size() == 1) { return lower_bounds_[0]; } - assert(lower_bounds_.size() > 1); assert(0 <= index && index < static_cast(lower_bounds_.size())); return lower_bounds_[index]; } @@ -377,7 +274,6 @@ double NumberNode::upper_bound(ssize_t index) const { if (upper_bounds_.size() == 1) { return upper_bounds_[0]; } - assert(upper_bounds_.size() > 1); assert(0 <= index && index < static_cast(upper_bounds_.size())); return upper_bounds_[index]; } @@ -391,100 +287,214 @@ double NumberNode::upper_bound() const { } void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) const { + auto ptr = data_ptr(state); value = std::clamp(value, lower_bound(index), upper_bound(index)); // Assert that i is a valid index occurs in data_ptr->set(). // Set occurs IFF `value` != buffer[i] . - data_ptr(state)->set(index, value); + if (ptr->set(index, value)) { + // Update the bound axis sums. + update_bound_axis_slice_sums(state, index, value - diff(state).back().old); + assert(satisfies_axis_wise_bounds(state)); + } } -ssize_t NumberNode::num_bound_axes() const { - return static_cast(bound_axes_info_.size()); -}; +const std::vector& NumberNode::axis_wise_bounds() const { return bound_axes_info_; } -const BoundAxisInfo* NumberNode::bound_axis_info(const ssize_t axis) const { - assert(axis >= 0 && axis < bound_axes_info_.size()); - return &bound_axes_info_[axis]; -}; +const std::vector>& NumberNode::bound_axis_sums(State& state) const { + return data_ptr(state)->bound_axes_sums; +} -ssize_t NumberNode::num_hyperslice_along_bound_axis(State& state, const ssize_t axis) const { - assert(axis >= 0 && axis < data_ptr(state)->bound_axes_sums.size()); - return data_ptr(state)->bound_axes_sums[axis].size(); +template +double get_extreme_index_wise_bound(const std::vector& bound) { + assert(bound.size() > 0); + std::vector::const_iterator it; + if (maximum) { + it = std::max_element(bound.begin(), bound.end()); + } else { + it = std::min_element(bound.begin(), bound.end()); + } + return *it; } -double NumberNode::bound_axis_hyperslice_sum(State& state, const ssize_t axis, - const ssize_t slice) const { - assert(axis >= 0 && slice >= 0); - assert(axis < data_ptr(state)->bound_axes_sums.size()); - assert(slice < data_ptr(state)->bound_axes_sums[axis].size()); - return data_ptr(state)->bound_axes_sums[axis][slice]; +void check_index_wise_bounds(const NumberNode& node, const std::vector& lower_bounds_, + const std::vector& upper_bounds_) { + bool index_wise_bound = false; + // If lower bound is index-wise, it must be correct size. + if (lower_bounds_.size() > 1) { + index_wise_bound = true; + if (static_cast(lower_bounds_.size()) != node.size()) { + throw std::invalid_argument("lower_bound must match size of node"); + } + } + // If upper bound is index-wise, it must be correct size. + if (upper_bounds_.size() > 1) { + index_wise_bound = true; + if (static_cast(upper_bounds_.size()) != node.size()) { + throw std::invalid_argument("upper_bound must match size of node"); + } + } + // If at least one of the bounds is index-wise, check that there are no + // violations at any of the indices. + if (index_wise_bound) { + for (ssize_t i = 0, stop = node.size(); i < stop; ++i) { + if (node.lower_bound(i) > node.upper_bound(i)) { + throw std::invalid_argument("Bounds of index " + std::to_string(i) + " clash"); + } + } + } } -// /// Check whether the axis-wise bound is satisfied for the given hyperslice -// void check_hyperslice(const BoundAxisInfo& bound_axis_info, const ssize_t slice, -// const double slice_sum) { -// const double rhs_bound = bound_axis_info.get_bound(slice); -// std::cout << slice_sum; -// -// switch (bound_axis_info.get_operator(slice)) { -// case Equal: -// std::cout << " == " << rhs_bound << std::endl; -// if (slice_sum == rhs_bound) return; -// case LessEqual: -// std::cout << " <= " << rhs_bound << std::endl; -// if (slice_sum <= rhs_bound) return; -// case GreaterEqual: -// std::cout << " >= " << rhs_bound << std::endl; -// if (slice_sum >= rhs_bound) return; -// default: -// throw std::invalid_argument("Invalid axis-wise bound operator"); -// } -// -// throw std::invalid_argument("Initialized state does not satisfy axis-wise bounds."); -// } +/// Check the user defined axis-wise bounds for NumberNode +void check_axis_wise_bounds(const std::vector& bound_axes_info, + const std::span shape) { + if (bound_axes_info.size() == 0) return; // No bound axes to check. -bool NumberNode::satisfies_axis_wise_bounds(State& state) const { - const ssize_t num_bound_axes = this->num_bound_axes(); - if (num_bound_axes == 0) return true; // No bounds to satisfy + // Used to asses if an axis have been bound multiple times. + std::vector axis_bound(shape.size(), false); - // Grab the hyperslice sums of all bound axes - const std::vector>& bound_axes_sums = - data_ptr(state)->bound_axes_sums; - assert(num_bound_axes == bound_axes_sums.size()); + // For each set of bound axis data + for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { + const ssize_t axis = bound_axis_info.axis; - for (ssize_t bound_axis = 0; bound_axis < num_bound_axes; ++bound_axis) { - // Grab the stateless axis-wise bound data for the bound axis - const BoundAxisInfo& bound_axis_info = this->bound_axes_info_[bound_axis]; + if (axis < 0 || axis >= static_cast(shape.size())) { + throw std::invalid_argument( + "Invalid bound axis: " + std::to_string(axis) + + ". Note, negative indexing is not supported for axis-wise bounds."); + } - // Grab the sums of all hyperslices along the bound axis - const std::vector& bound_axis_sums = bound_axes_sums[bound_axis]; + // The number of operators defined for the given bound axis + const ssize_t num_operators = static_cast(bound_axis_info.operators.size()); + if ((num_operators > 1) && (num_operators != shape[axis])) { + throw std::invalid_argument( + "Invalid number of axis-wise operators along axis: " + std::to_string(axis) + + " given axis size: " + std::to_string(shape[axis])); + } - // For each hyperslice along said axis - for (ssize_t slice = 0, stop = bound_axis_sums.size(); slice < stop; ++slice) { - const double rhs_bound = bound_axis_info.get_bound(slice); - const double slice_sum = bound_axis_sums[slice]; + // The number of operators defined for the given bound axis + const ssize_t num_bounds = static_cast(bound_axis_info.bounds.size()); + if ((num_bounds > 1) && (num_bounds != shape[axis])) { + throw std::invalid_argument( + "Invalid number of axis-wise bounds along axis: " + std::to_string(axis) + + " given axis size: " + std::to_string(shape[axis])); + } + // Checked in BoundAxisInfo constructor + assert(num_operators == num_bounds || num_operators == 1 || num_bounds == 1); + + if (axis_bound[axis]) { + throw std::invalid_argument( + "Cannot define multiple axis-wise bounds for a single axis."); + } + axis_bound[axis] = true; + } + + // *Currently*, we only support axis-wise bounds for up to one axis. + if (bound_axes_info.size() > 1) { + throw std::invalid_argument("Axis-wise bounds are supported for at most one axis."); + } +} + +// Base class to be used as interfaces. +NumberNode::NumberNode(std::span shape, std::vector lower_bound, + std::vector upper_bound, + std::optional> bound_axes) + : ArrayOutputMixin(shape), + min_(get_extreme_index_wise_bound(lower_bound)), + max_(get_extreme_index_wise_bound(upper_bound)), + lower_bounds_(std::move(lower_bound)), + upper_bounds_(std::move(upper_bound)), + bound_axes_info_(bound_axes ? std::move(*bound_axes) : std::vector{}) { + if ((shape.size() > 0) && (shape[0] < 0)) { + throw std::invalid_argument("Number array cannot have dynamic size."); + } + + if (max_ < min_) { + throw std::invalid_argument("Invalid range for number array provided."); + } + + check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); + check_axis_wise_bounds(bound_axes_info_, this->shape()); +} + +bool NumberNode::satisfies_axis_wise_bounds(State& state) const { + const auto& bound_axes_info = bound_axes_info_; + if (bound_axes_info.size() == 0) return true; // No axis-wise bounds to satisfy + + // Get the hyperslice sums of all bound axes. + const auto& bound_axes_sums = data_ptr(state)->bound_axes_sums; + assert(bound_axes_info.size() == bound_axes_sums.size()); + + for (ssize_t bound_axis = 0, stop = static_cast(bound_axes_info.size()); + bound_axis < stop; ++bound_axis) { + // Get the stateless axis-wise bound for the bound axis + const BoundAxisInfo& bound_axis_info = bound_axes_info[bound_axis]; + // Get the sums of all hyperslices along the bound axis + const std::vector& bound_axis_sums = bound_axes_sums[bound_axis]; + + // Possible To Do: We could "optimize" here if axis has uniform bounds + // and or operators for all slices. + for (ssize_t slice = 0, stop = static_cast(bound_axis_sums.size()); slice < stop; + ++slice) { // Check whether the axis-wise bound is satisfied for the given hyperslice switch (bound_axis_info.get_operator(slice)) { case Equal: - if (slice_sum != rhs_bound) return false; - continue; + if (bound_axis_sums[slice] == bound_axis_info.get_bound(slice)) continue; + return false; case LessEqual: - if (slice_sum > rhs_bound) return false; - continue; + if (bound_axis_sums[slice] <= bound_axis_info.get_bound(slice)) continue; + return false; case GreaterEqual: - if (slice_sum < rhs_bound) return false; - continue; + if (bound_axis_sums[slice] >= bound_axis_info.get_bound(slice)) continue; + return false; default: throw std::invalid_argument("Invalid axis-wise bound operator"); } } } - return true; } +void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, + const double value_change) const { + const auto& bound_axes_info = bound_axes_info_; + if (bound_axes_info.size() == 0) return; // No axis-wise bounds to satisfy + + // Obtain the multidimensional indices for `index` so we can identify the + // slices `index` lies on per bound axis. + // Possible To Do: We could optimize this get the bound axes indices only. + const std::vector multi_index = unravel_index(index, this->shape()); + assert(bound_axes_info.size() <= multi_index.size()); + // Get the hyperslice sums of all bound axes. + auto& bound_axes_sums = data_ptr(state)->bound_axes_sums; + assert(bound_axes_info.size() == bound_axes_sums.size()); + + for (ssize_t bound_axis = 0, stop = static_cast(bound_axes_info.size()); + bound_axis < stop; ++bound_axis) { + assert(bound_axes_info[bound_axis].axis < static_cast(multi_index.size())); + // Get the slice along the bound axis the `value_change` occurs in + const ssize_t slice = multi_index[bound_axes_info[bound_axis].axis]; + assert(slice < static_cast(bound_axes_sums[bound_axis].size())); + // Offset running sum in slice + bound_axes_sums[bound_axis][slice] += value_change; + } +} + // Integer Node *************************************************************** +/// Check the user defined axis-wise bounds for IntegerNode +void check_integrality_of_axis_wise_bounds(const std::vector& bound_axes_info) { + if (bound_axes_info.size() == 0) return; // No bound axes to check. + + for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { + for (const double& bound : bound_axis_info.bounds) { + if (bound != std::round(bound)) { + throw std::invalid_argument( + "Axis wise bounds for integral number arrays must be intregral."); + } + } + } +} + IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, @@ -498,6 +508,8 @@ IntegerNode::IntegerNode(std::span shape, if (min_ < minimum_lower_bound || max_ > maximum_upper_bound) { throw std::invalid_argument("range provided for integers exceeds supported range"); } + + check_integrality_of_axis_wise_bounds(bound_axes_info_); } IntegerNode::IntegerNode(std::initializer_list shape, @@ -564,13 +576,18 @@ bool IntegerNode::is_valid(ssize_t index, double value) const { } void IntegerNode::set_value(State& state, ssize_t index, double value) const { + auto ptr = 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 data_ptr->set(). - // Set occurs IFF `value` != buffer[i] . - data_ptr(state)->set(index, value); + // set() occurs IFF `value` != buffer[i]. + if (ptr->set(index, value)) { + // Update the bound axis. + update_bound_axis_slice_sums(state, index, value - diff(state).back().old); + assert(satisfies_axis_wise_bounds(state)); + } } double IntegerNode::default_value(ssize_t index) const { @@ -675,24 +692,37 @@ void BinaryNode::flip(State& state, ssize_t i) const { // Variable should not be fixed. assert(lower_bound(i) != upper_bound(i)); // Assert that i is a valid index occurs in ptr->set(). - // Set occurs IFF `value` != buffer[i] . - ptr->set(i, !ptr->get(i)); + // set() occurs IFF `value` != buffer[i]. + if (ptr->set(i, !ptr->get(i))) { + // If value changed from 0 -> 1, update the bound axis sums by 1. + // If value changed from 1 -> 0, update the bound axis sums by -1. + update_bound_axis_slice_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); + assert(satisfies_axis_wise_bounds(state)); + } } void BinaryNode::set(State& state, ssize_t i) const { // We expect the set to obey the index-wise bounds. assert(upper_bound(i) == 1.0); // Assert that i is a valid index occurs in data_ptr->set(). - // Set occurs IFF `value` != buffer[i] . - data_ptr(state)->set(i, 1.0); + // set() occurs IFF `value` != buffer[i]. + if (data_ptr(state)->set(i, 1.0)) { + // If value changed from 0 -> 1, update the bound axis sums by 1. + update_bound_axis_slice_sums(state, i, 1.0); + assert(satisfies_axis_wise_bounds(state)); + } } void BinaryNode::unset(State& state, ssize_t i) const { // We expect the set to obey the index-wise bounds. assert(lower_bound(i) == 0.0); // Assert that i is a valid index occurs in data_ptr->set(). - // Set occurs IFF `value` != buffer[i] . - data_ptr(state)->set(i, 0.0); + // set occurs IFF `value` != buffer[i]. + if (data_ptr(state)->set(i, 0.0)) { + // If value changed from 1 -> 0, update the bound axis sums by -1. + update_bound_axis_slice_sums(state, i, -1.0); + assert(satisfies_axis_wise_bounds(state)); + } } } // namespace dwave::optimization diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index d0bf20f5..598bdb4e 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -18,6 +18,7 @@ #include "catch2/catch_test_macros.hpp" #include "catch2/matchers/catch_matchers.hpp" #include "catch2/matchers/catch_matchers_all.hpp" +#include "catch2/matchers/catch_matchers_range_equals.hpp" #include "dwave-optimization/graph.hpp" #include "dwave-optimization/nodes/numbers.hpp" @@ -484,7 +485,7 @@ TEST_CASE("BinaryNode") { "Number array cannot have dynamic size."); } - GIVEN("(2x3)-Binary node with axis-wise bounds on the invalid axis -1") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { BoundAxisInfo bound_axis{-1, std::vector{Equal}, std::vector{1.0}}; REQUIRE_THROWS_WITH(graph.emplace_node( @@ -494,7 +495,7 @@ TEST_CASE("BinaryNode") { "axis-wise bounds."); } - GIVEN("(2x3)-Binary node with axis-wise bounds on the invalid axis 2") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { BoundAxisInfo bound_axis{2, std::vector{Equal}, std::vector{1.0}}; REQUIRE_THROWS_WITH(graph.emplace_node( @@ -504,45 +505,45 @@ TEST_CASE("BinaryNode") { "axis-wise bounds."); } - GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too many operators.") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many operators.") { BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal, Equal, Equal}, std::vector{1.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise operators along axis: 1 given axis shape: 3"); + "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); } - GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too few operators.") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal}, std::vector{1.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise operators along axis: 1 given axis shape: 3"); + "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); } - GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too many bounds.") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many bounds.") { BoundAxisInfo bound_axis{1, std::vector{LessEqual}, std::vector{1.0, 2.0, 3.0, 4.0}}; REQUIRE_THROWS_WITH(graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis shape: 3"); + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); } - GIVEN("(2x3)-Binary node with axis-wise bounds on axis: 1 with too few bounds.") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { BoundAxisInfo bound_axis{1, std::vector{LessEqual}, std::vector{1.0, 2.0}}; REQUIRE_THROWS_WITH(graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis shape: 3"); + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); } - GIVEN("(2x3)-Binary node with duplicate axis-wise bounds on axis: 1") { + GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { BoundAxisInfo bound_axis{1, std::vector{Equal}, std::vector{1.0}}; REQUIRE_THROWS_WITH( @@ -552,7 +553,7 @@ TEST_CASE("BinaryNode") { "Cannot define multiple axis-wise bounds for a single axis."); } - GIVEN("(2x3)-Binary node with axis-wise bounds on axes: 0 and 1") { + GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { BoundAxisInfo bound_axis_0{0, std::vector{LessEqual}, std::vector{1.0}}; BoundAxisInfo bound_axis_1{1, std::vector{LessEqual}, @@ -564,55 +565,259 @@ TEST_CASE("BinaryNode") { "Axis-wise bounds are supported for at most one axis."); } - GIVEN("(2x3x4)-Binary node with an axis-wise bound on axis: 1") { + GIVEN("(2x3x4)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); - BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{4.0, 4.0, 6.0}}; + BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, + std::vector{1.0, 2.0, 3.0}}; auto bnode_ptr = graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, std::vector{bound_axis}); THEN("Axis wise bound is correct") { - CHECK(bnode_ptr->num_bound_axes() == 1.0); - const BoundAxisInfo* bnode_bound_axis_ptr = bnode_ptr->bound_axis_info(0); - CHECK(bound_axis.axis == bnode_bound_axis_ptr->axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis_ptr->operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis_ptr->bounds)); + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); } - WHEN("We initialize an invalid state") { + WHEN("We initialize three invalid states") { auto state = graph.empty_state(); - std::vector init_values(2 * 3 * 4, 1); + // This state violates the 0th hyperslice along axis 0 + std::vector init_values{1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1}; // import numpy as np - // a = np.ones((2,3,4)) - // a.sum(axis=(0, 2)) - // array([8, 8, 8]) + // a = np.asarray([1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) + // a = a.reshape(3, 2, 2) + // a.sum(axis=(1, 2)) + // >>> array([2, 2, 4]) + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + + state = graph.empty_state(); + // This state violates the 1st hyperslice along axis 0 + init_values = {0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1}; + // import numpy as np + // a = np.asarray([0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + // a = a.reshape(3, 2, 2) + // a.sum(axis=(1, 2)) + // >>> array([1, 3, 4]) + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + + state = graph.empty_state(); + // This state violates the 2nd hyperslice along axis 0 + init_values = {0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0}; + // import numpy as np + // a = np.asarray([0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0]) + // a = a.reshape(3, 2, 2) + // a.sum(axis=(1, 2)) + // >>> array([1, 2, 2]) CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), "Initialized values do not satisfy axis-wise bounds."); } - WHEN("We initialize a state") { + WHEN("We initialize a valid state") { auto state = graph.empty_state(); - std::vector init_values{ - 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, - }; - std::cout << "here" << std::endl; + std::vector init_values{0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1}; bnode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - // import numpy as np - // a = np.array([1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 0, - // ... 1, 0, 1, 1, 1, 1, 0]) - // a = a.reshape(2,3,4) - // a.sum(axis=(0, 2)) - // array([3, 4, 5]) - THEN("The sums of each hyperslice along axis 1 are correct") { - CHECK(bnode_ptr->num_hyperslice_along_bound_axis(state, 0) == 3); - CHECK(bnode_ptr->bound_axis_hyperslice_sum(state, 0, 0) == 3); - CHECK(bnode_ptr->bound_axis_hyperslice_sum(state, 0, 1) == 4); - CHECK(bnode_ptr->bound_axis_hyperslice_sum(state, 0, 2) == 5); + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + // **Python Code 1** + // import numpy as np + // a = np.asarray([0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) + // a = a.reshape(3, 2, 2) + // a.sum(axis=(1, 2)) + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + THEN("We exchange() some values") { + bnode_ptr->exchange(state, 0, 3); // Does nothing. + bnode_ptr->exchange(state, 1, 6); // Does nothing. + bnode_ptr->exchange(state, 1, 3); + std::swap(init_values[0], init_values[3]); + std::swap(init_values[1], init_values[6]); + std::swap(init_values[1], init_values[3]); + // state is now: [0, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 1** + // a[np.unravel_index(1, a.shape)] = 0 + // a[np.unravel_index(3, a.shape)] = 1 + // a.sum(axis=(1, 2)) + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK(bnode_ptr->diff(state).size() == 2); // 2 updates per exchange + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK(bnode_ptr->diff(state).size() == 0); + } + } + } + + THEN("We clip_and_set_value() some values") { + bnode_ptr->clip_and_set_value(state, 5, -1); // Does nothing. + bnode_ptr->clip_and_set_value(state, 7, -1); + bnode_ptr->clip_and_set_value(state, 9, 1); // Does nothing. + bnode_ptr->clip_and_set_value(state, 11, 0); + bnode_ptr->clip_and_set_value(state, 11, 1); + bnode_ptr->clip_and_set_value(state, 10, 0); + init_values[5] = 0; + init_values[7] = 0; + init_values[9] = 1; + init_values[11] = 1; + init_values[10] = 0; + // state is now: [0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 0, 1] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 1** + // a[np.unravel_index(5, a.shape)] = 0 + // a[np.unravel_index(7, a.shape)] = 0 + // a[np.unravel_index(9, a.shape)] = 1 + // a[np.unravel_index(11, a.shape)] = 1 + // a[np.unravel_index(10, a.shape)] = 0 + // a.sum(axis=(1, 2)) + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK(bnode_ptr->diff(state).size() == 4); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK(bnode_ptr->diff(state).size() == 0); + } + } + } + + THEN("We set_value() some values") { + bnode_ptr->set_value(state, 0, 0); // Does nothing. + bnode_ptr->set_value(state, 6, 0); + bnode_ptr->set_value(state, 7, 0); + bnode_ptr->set_value(state, 4, 1); + bnode_ptr->set_value(state, 10, 1); // Does nothing. + bnode_ptr->set_value(state, 11, 0); + init_values[0] = 0; + init_values[6] = 0; + init_values[7] = 0; + init_values[4] = 1; + init_values[10] = 1; + init_values[11] = 0; + // state is now: [0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 1** + // a[np.unravel_index(0, a.shape)] = 0 + // a[np.unravel_index(6, a.shape)] = 0 + // a[np.unravel_index(7, a.shape)] = 0 + // a[np.unravel_index(4, a.shape)] = 1 + // a[np.unravel_index(10, a.shape)] = 1 + // a[np.unravel_index(11, a.shape)] = 0 + // a.sum(axis=(1, 2)) + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK(bnode_ptr->diff(state).size() == 4); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK(bnode_ptr->diff(state).size() == 0); + } + } + } + + THEN("We flip() some values") { + bnode_ptr->flip(state, 6); // 1 -> 0 + bnode_ptr->flip(state, 4); // 0 -> 1 + bnode_ptr->flip(state, 11); // 1 -> 0 + init_values[6] = !init_values[6]; + init_values[4] = !init_values[4]; + init_values[11] = !init_values[11]; + // state is now: [0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 0] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 1** + // a[np.unravel_index(6, a.shape)] = 0 + // a[np.unravel_index(4, a.shape)] = 1 + // a[np.unravel_index(11, a.shape)] = 0 + // a.sum(axis=(1, 2)) + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 3})); + CHECK(bnode_ptr->diff(state).size() == 3); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK(bnode_ptr->diff(state).size() == 0); + } + } + } + + THEN("We unset() some values") { + bnode_ptr->unset(state, 0); // Does nothing. + bnode_ptr->unset(state, 6); + bnode_ptr->unset(state, 11); + init_values[0] = 0; + init_values[6] = 0; + init_values[11] = 0; + // state is now: [0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 1** + // a[np.unravel_index(0, a.shape)] = 0 + // a[np.unravel_index(6, a.shape)] = 0 + // a[np.unravel_index(11, a.shape)] = 0 + // a.sum(axis=(1, 2)) + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK(bnode_ptr->diff(state).size() == 2); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We commit and set() some values") { + graph.commit(state); + + bnode_ptr->set(state, 10); // Does nothing. + bnode_ptr->set(state, 11); + init_values[10] = 1; + init_values[11] = 1; + // state is now: [0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1] + + THEN("The bound axis sums updated correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 4})); + CHECK(bnode_ptr->diff(state).size() == 1); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], + RangeEquals({1, 1, 3})); + CHECK(bnode_ptr->diff(state).size() == 0); + } + } + } } } } @@ -913,6 +1118,268 @@ TEST_CASE("IntegerNode") { REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{-1, 3}), "Number array cannot have dynamic size."); } + + GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { + BoundAxisInfo bound_axis{-2, std::vector{Equal}, + std::vector{20.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid bound axis: -2. Note, negative indexing is not supported for " + "axis-wise bounds."); + } + + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { + BoundAxisInfo bound_axis{3, std::vector{Equal}, + std::vector{10.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid bound axis: 3. Note, negative indexing is not supported for " + "axis-wise bounds."); + } + + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many operators.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal, Equal, Equal}, + std::vector{-10.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); + } + + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal}, + std::vector{-11.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); + } + + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many bounds.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual}, + std::vector{-10.0, 20.0, 30.0, 40.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + } + + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { + BoundAxisInfo bound_axis{1, std::vector{LessEqual}, + std::vector{111.0, -223.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + } + + GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { + BoundAxisInfo bound_axis{1, std::vector{Equal}, + std::vector{100.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis, bound_axis}), + "Cannot define multiple axis-wise bounds for a single axis."); + } + + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { + BoundAxisInfo bound_axis_0{0, std::vector{LessEqual}, + std::vector{11.0}}; + BoundAxisInfo bound_axis_1{1, std::vector{LessEqual}, + std::vector{12.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis_0, bound_axis_1}), + "Axis-wise bounds are supported for at most one axis."); + } + + GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { + BoundAxisInfo bound_axis{2, std::vector{LessEqual}, + std::vector{11.0, 12.0001, 0.0, 0.0}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Axis wise bounds for integral number arrays must be intregral."); + } + + GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{1, std::vector{Equal, LessEqual, GreaterEqual}, + std::vector{11.0, 2.0, 5.0}}; + + auto inode_ptr = graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + const BoundAxisInfo inode_bound_axis_ptr = inode_ptr->axis_wise_bounds().data()[0]; + CHECK(bound_axis.axis == inode_bound_axis_ptr.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(inode_bound_axis_ptr.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(inode_bound_axis_ptr.bounds)); + } + + WHEN("We initialize three invalid states") { + auto state = graph.empty_state(); + // This state violates the 0th hyperslice along axis 1 + std::vector init_values{5, 6, 0, 0, 3, 1, 4, 0, 2, 0, 0, 3}; + // import numpy as np + // a = np.asarray([5, 6, 0, 0, 3, 1, 4, 0, 2, 0, 0, 3]) + // a = a.reshape(2, 3, 2) + // a.sum(axis=(0, 2)) + // >>> array([15, 2, 7]) + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + + state = graph.empty_state(); + // This state violates the 1st hyperslice along axis 1 + init_values = {5, 2, 0, 0, 3, 1, 4, 0, 2, 1, 0, 3}; + // import numpy as np + // a = np.asarray([5, 2, 0, 0, 3, 1, 4, 0, 2, 1, 0, 3]) + // a = a.reshape(2, 3, 2) + // a.sum(axis=(0, 2)) + // >>> array([11, 3, 7]) + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + + state = graph.empty_state(); + // This state violates the 2nd hyperslice along axis 1 + init_values = {5, 2, 0, 0, 3, 1, 4, 0, 1, 0, 0, 0}; + // import numpy as np + // a = np.asarray([5, 2, 0, 0, 3, 1, 4, 0, 1, 0, 0, 0]) + // a = a.reshape(2, 3, 2) + // a.sum(axis=(0, 2)) + // >>> array([11, 1, 4]) + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + + WHEN("We initialize a valid state") { + auto state = graph.empty_state(); + std::vector init_values{5, 2, 0, 0, 3, 1, 4, 0, 2, 0, 0, 3}; + inode_ptr->initialize_state(state, init_values); + graph.initialize_state(state); + + auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + // **Python Code 2** + // import numpy as np + // a = np.asarray([5, 2, 0, 0, 3, 1, 4, 0, 2, 0, 0, 3]) + // a = a.reshape(2, 3, 2) + // a.sum(axis=(0, 2)) + // >>> array([11, 2, 7]) + CHECK(inode_ptr->bound_axis_sums(state).size() == 1); + CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 3); + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 2, 7})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); + } + + THEN("We exchange() some values") { + inode_ptr->exchange(state, 2, 3); // Does nothing. + inode_ptr->exchange(state, 1, 8); // Does nothing. + inode_ptr->exchange(state, 8, 10); + inode_ptr->exchange(state, 0, 1); + std::swap(init_values[2], init_values[3]); + std::swap(init_values[1], init_values[8]); + std::swap(init_values[8], init_values[10]); + std::swap(init_values[0], init_values[1]); + // state is now: [2, 5, 0, 0, 3, 1, 4, 0, 0, 0, 2, 3] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 2** + // a[np.unravel_index(8, a.shape)] = 0 + // a[np.unravel_index(10, a.shape)] = 2 + // a[np.unravel_index(0, a.shape)] = 2 + // a[np.unravel_index(1, a.shape)] = 5 + // a.sum(axis=(0, 2)) + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 0, 9})); + CHECK(inode_ptr->diff(state).size() == 4); // 2 updates per exchange + CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 2, 7})); + CHECK(inode_ptr->diff(state).size() == 0); + } + } + } + + THEN("We clip_and_set_value() some values") { + inode_ptr->clip_and_set_value(state, 0, 5); // Does nothing. + inode_ptr->clip_and_set_value(state, 8, -300); + inode_ptr->clip_and_set_value(state, 10, 100); + init_values[8] = -5; + init_values[10] = 8; + // state is now: [5, 2, 0, 0, 3, 1, 4, 0, -5, 0, 8, 3] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 2** + // a[np.unravel_index(8, a.shape)] = -5 + // a[np.unravel_index(10, a.shape)] = 8 + // a.sum(axis=(0, 2)) + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, -5, 15})); + CHECK(inode_ptr->diff(state).size() == 2); + CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 2, 7})); + CHECK(inode_ptr->diff(state).size() == 0); + } + } + } + + THEN("We set_value() some values") { + inode_ptr->set_value(state, 0, 5); // Does nothing. + inode_ptr->set_value(state, 8, 0); + inode_ptr->set_value(state, 9, 1); + inode_ptr->set_value(state, 10, 5); + inode_ptr->set_value(state, 11, 0); + init_values[0] = 5; + init_values[8] = 0; + init_values[9] = 1; + init_values[10] = 5; + init_values[11] = 0; + // state is now: [5, 2, 0, 0, 3, 1, 4, 0, 0, 1, 5, 0] + + THEN("The bound axis sums and state updated correctly") { + // Cont. w/ Python code at **Python Code 2** + // a[np.unravel_index(0, a.shape)] = 5 + // a[np.unravel_index(8, a.shape)] = 0 + // a[np.unravel_index(9, a.shape)] = 1 + // a[np.unravel_index(10, a.shape)] = 5 + // a[np.unravel_index(11, a.shape)] = 0 + // a.sum(axis=(0, 2)) + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 1, 9})); + CHECK(inode_ptr->diff(state).size() == 4); + CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); + } + + AND_WHEN("We revert") { + graph.revert(state); + + THEN("The bound axis sums reverted correctly") { + CHECK_THAT(bound_axis_sums[0], RangeEquals({11, 2, 7})); + CHECK(inode_ptr->diff(state).size() == 0); + } + } + } + } + } } } // namespace dwave::optimization From e67faa5d9b6ea52a6869add36ff09ae665d9b7d6 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 28 Jan 2026 13:51:26 -0800 Subject: [PATCH 04/22] Simplify NumberNodeStateData Make use of BufferIterators to compute the sum of the values within each hyperslice along each bound axis as opposed making a custom method to do this. --- .../dwave-optimization/nodes/numbers.hpp | 6 +- dwave/optimization/src/nodes/numbers.cpp | 273 +++++++----------- 2 files changed, 109 insertions(+), 170 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 29993d90..2735c173 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -152,18 +152,16 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// Default value in a given index. virtual double default_value(ssize_t index) const = 0; - /// Check whether all axis-wise bounds are satisfied - bool satisfies_axis_wise_bounds(State& state) const; - /// Update the running bound axis sums where `index` is changed by /// `value_change` in a given state. void update_bound_axis_slice_sums(State& state, const ssize_t index, const double value_change) const; + /// Statelss global minimum and maximum of the values stored in NumberNode. double min_; double max_; - /// Stateless index-wise upper and lower bounds + /// Stateless index-wise upper and lower bounds. std::vector lower_bounds_; std::vector upper_bounds_; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 9fad6545..574b8f59 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -15,6 +15,8 @@ #include "dwave-optimization/nodes/numbers.hpp" #include +#include +#include #include #include #include @@ -61,116 +63,17 @@ BoundAxisOperator BoundAxisInfo::get_operator(const ssize_t slice) const { return operators[slice]; } -struct NumberNodeDataHelper_ { - NumberNodeDataHelper_(std::vector input, - const std::vector& bound_axes_info, - const std::span& shape, - const std::span& strides) - : values(std::move(input)) { - if (bound_axes_info.empty()) return; // No axis sums to compute. - compute_bound_axis_hyperslice_sums(bound_axes_info, shape, strides); - } - - /// Variable assignment to NumberNode - std::vector values; - /// For each bound axis and for each hyperslice along said axis, we track - /// the sum of the values within the hyperslice. - /// bound_axes_sums[i][j] = "sum of the values within the jth hyperslice along - /// the ith bound axis" - std::vector> bound_axes_sums; - - /// Determine the sum of the values of each hyperslice along each bound - /// axis given the variable assignment of NumberNode. - void compute_bound_axis_hyperslice_sums(const std::vector& bound_axes_info, - const std::span& shape, - const std::span& strides) { - const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); - bound_axes_sums.reserve(num_bound_axes); - - // For each variable assignment to NumberNode (stored in `values`), we need - // to add the variables value to the running sum for each hyperslice it is - // contained in (and that we are tracking). For each such variable i and - // each bound axis j, we can identify which hyperslice i lies in along j - // via `unravel_index(i, shape)[j]`. However, this is inefficient. Instead - // we track the running multidimensional index (for each bound axis we care - // about) and adjust it based on the strides of the NumberNode array as we - // iterate over the variable assignments of the NumberNode. - // - // To do this easily, we first compute the element strides from the byte - // strides of the NumberNode array. Formally - // element_strides[i] = "# of elements need get to the next hyperslice - // along the ith bound axis" - const ssize_t bytes_per_element = static_cast(sizeof(double)); - std::vector element_strides; - element_strides.reserve(num_bound_axes); - // A running stride counter for each bound axis. - // When remaining_axis_strides[i] = 0, we have moved to the next - // hyperslice along the ith bound axis. - std::vector remaining_axis_strides; - remaining_axis_strides.reserve(num_bound_axes); - // Running hyperslice index per bound axis - std::vector hyperslice_index; - hyperslice_index.reserve(num_bound_axes); - - // For each bound axis - for (ssize_t i = 0; i < num_bound_axes; ++i) { - const ssize_t bound_axis = bound_axes_info[i].axis; - assert(0 <= bound_axis && bound_axis < static_cast(shape.size())); - - const ssize_t num_axis_slices = shape[bound_axis]; - // Initialize the sums for each hyperslice along the bound axis. - bound_axes_sums.emplace_back(std::vector(num_axis_slices, 0.0)); - - // Update element stride data - assert(strides[bound_axis] % bytes_per_element == 0); - element_strides.emplace_back(strides[bound_axis] / bytes_per_element); - // Initialize by the total # of element_strides along the bound axis - remaining_axis_strides.push_back(element_strides[i]); - - // Initialize hyperslice index to 0 - hyperslice_index.emplace_back(0); - } - - // Iterate over variable assignments of NumberNode. - for (ssize_t i = 0, stop = static_cast(values.size()); i < stop; ++i) { - // Iterate over the bound axes. - for (ssize_t j = 0; j < num_bound_axes; ++j) { - const ssize_t bound_axis = bound_axes_info[j].axis; - // Check the computation of the hyperslice - assert(unravel_index(i, shape)[bound_axis] == hyperslice_index[j]); - // Accumulate sum in hyperslice along jth bound axis - bound_axes_sums[j][hyperslice_index[j]] += values[i]; - - // Update running multidimensional index - if (--remaining_axis_strides[j] == 0) { - // Moved to next hyperslice, reset `remaining_axis_strides` - remaining_axis_strides[j] = element_strides[j]; - - // Increment the multi_index along bound axis modulo the # - // of hyperslice along said axis - if (++hyperslice_index[j] == shape[bound_axis]) { - hyperslice_index[j] = 0; - } - } - } - } - } -}; - /// State dependant data attached to NumberNode struct NumberNodeStateData : public ArrayNodeStateData { - NumberNodeStateData(std::vector input, - const std::vector& bound_axes_info, - const std::span& shape, - const std::span& strides) - : NumberNodeStateData( - NumberNodeDataHelper_(std::move(input), bound_axes_info, shape, strides)) {} - - NumberNodeStateData(NumberNodeDataHelper_&& helper) - : ArrayNodeStateData(std::move(helper.values)), - bound_axes_sums(helper.bound_axes_sums), - prior_bound_axes_sums(std::move(helper.bound_axes_sums)) {} - + NumberNodeStateData(std::vector input) : ArrayNodeStateData(std::move(input)) {} + NumberNodeStateData(std::vector input, std::vector> bound_axes_sums) + : ArrayNodeStateData(std::move(input)), + bound_axes_sums(std::move(bound_axes_sums)), + prior_bound_axes_sums(this->bound_axes_sums) {} + /// For each bound axis and for each hyperslice along said axis, we + /// track the sum of the values within the hyperslice. + /// bound_axes_sums[i][j] = "sum of the values within the jth + /// hyperslice along the ith bound axis" std::vector> bound_axes_sums; // Store a copy for NumberNode::revert() and commit() std::vector> prior_bound_axes_sums; @@ -188,22 +91,96 @@ double NumberNode::min() const { return min_; } double NumberNode::max() const { return max_; } +std::vector> get_bound_axes_sums( + const std::vector& number_data, const std::vector bound_axes_info, + std::span node_shape, std::span node_strides) { + assert(node_shape.size() == node_strides.size()); + assert(bound_axes_info.size() <= node_shape.size()); + assert(std::accumulate(node_shape.begin(), node_shape.end(), 1, std::multiplies()) == + static_cast(number_data.size())); + + const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); + // For each bound axis, initialize the sum of the values contained in each + // of it's hyperslice to 0. + std::vector> bound_axes_sums; + bound_axes_sums.reserve(num_bound_axes); + for (const BoundAxisInfo& axis_info : bound_axes_info) { + assert(0 <= axis_info.axis && axis_info.axis < static_cast(node_shape.size())); + bound_axes_sums.emplace_back(node_shape[axis_info.axis], 0.0); + } + + // Define a BufferIterator for number_data (contiguous block of doubles) + // given the shape and strides of the NumberNode. + BufferIterator it(number_data.data(), node_shape, node_strides); + + // Iterate over number_data. + for (; it != std::default_sentinel; ++it) { + // Increment the appropriate slice in each bound axis. + for (ssize_t i = 0; i < num_bound_axes; ++i) { + const ssize_t axis = bound_axes_info[i].axis; + assert(0 <= axis && axis < it.location().size()); + const ssize_t slice = it.location()[axis]; + assert(0 <= slice && slice < bound_axes_sums[i].size()); + bound_axes_sums[i][slice] += *it; + } + } + + return bound_axes_sums; +} + +bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, + const std::vector>& bound_axes_sums) { + assert(bound_axes_info.size() == bound_axes_sums.size()); + // Check that each hyperslice satisfies the axis-wise bounds. + for (ssize_t i = 0, stop_i = static_cast(bound_axes_info.size()); i < stop_i; ++i) { + const std::vector& bound_axis_sums = bound_axes_sums[i]; + const BoundAxisInfo& bound_axis_info = bound_axes_info[i]; + + for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); + slice < stop_slice; ++slice) { + switch (bound_axis_info.get_operator(slice)) { + case Equal: + if (bound_axis_sums[slice] != bound_axis_info.get_bound(slice)) return false; + break; + case LessEqual: + if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) return false; + break; + case GreaterEqual: + if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; + break; + default: + throw std::invalid_argument("Invalid axis-wise bound operator"); + } + } + } + return true; +} + void NumberNode::initialize_state(State& state, std::vector&& number_data) const { if (number_data.size() != static_cast(this->size())) { throw std::invalid_argument("Size of data provided does not match node size"); } + for (ssize_t index = 0, stop = this->size(); index < stop; ++index) { if (!is_valid(index, number_data[index])) { throw std::invalid_argument("Invalid data provided for node"); } } - emplace_data_ptr(state, std::move(number_data), bound_axes_info_, - this->shape(), this->strides()); + if (bound_axes_info_.size() == 0) { // No bound axes to consider. + emplace_data_ptr(state, std::move(number_data)); + return; + } - if (!this->satisfies_axis_wise_bounds(state)) { + std::vector> bound_axes_sums = + get_bound_axes_sums(number_data, bound_axes_info_, this->shape(), this->strides()); + + if (!satisfies_axis_wise_bounds(bound_axes_info_, bound_axes_sums)) { throw std::invalid_argument("Initialized values do not satisfy axis-wise bounds."); } + + emplace_data_ptr(state, std::move(number_data), + std::move(bound_axes_sums)); } void NumberNode::initialize_state(State& state) const { @@ -212,6 +189,7 @@ void NumberNode::initialize_state(State& state) const { for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { values.push_back(default_value(i)); } + /// Set all to mins initialize_state(state, std::move(values)); } @@ -230,7 +208,7 @@ void NumberNode::revert(State& state) const noexcept { } void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { - auto ptr = data_ptr(state); + auto ptr = data_ptr(state); // We expect the exchange to obey the index-wise bounds. assert(lower_bound(i) <= ptr->get(j)); assert(upper_bound(i) >= ptr->get(j)); @@ -246,7 +224,7 @@ void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { update_bound_axis_slice_sums(state, i, difference); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) update_bound_axis_slice_sums(state, j, -difference); - assert(satisfies_axis_wise_bounds(state)); + assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } } @@ -287,14 +265,14 @@ double NumberNode::upper_bound() const { } void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) const { - auto ptr = data_ptr(state); + auto ptr = data_ptr(state); value = std::clamp(value, lower_bound(index), upper_bound(index)); // Assert that i is a valid index occurs in data_ptr->set(). // Set occurs IFF `value` != buffer[i] . if (ptr->set(index, value)) { // Update the bound axis sums. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); - assert(satisfies_axis_wise_bounds(state)); + assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } } @@ -416,52 +394,13 @@ NumberNode::NumberNode(std::span shape, std::vector lower check_axis_wise_bounds(bound_axes_info_, this->shape()); } -bool NumberNode::satisfies_axis_wise_bounds(State& state) const { - const auto& bound_axes_info = bound_axes_info_; - if (bound_axes_info.size() == 0) return true; // No axis-wise bounds to satisfy - - // Get the hyperslice sums of all bound axes. - const auto& bound_axes_sums = data_ptr(state)->bound_axes_sums; - assert(bound_axes_info.size() == bound_axes_sums.size()); - - for (ssize_t bound_axis = 0, stop = static_cast(bound_axes_info.size()); - bound_axis < stop; ++bound_axis) { - // Get the stateless axis-wise bound for the bound axis - const BoundAxisInfo& bound_axis_info = bound_axes_info[bound_axis]; - // Get the sums of all hyperslices along the bound axis - const std::vector& bound_axis_sums = bound_axes_sums[bound_axis]; - - // Possible To Do: We could "optimize" here if axis has uniform bounds - // and or operators for all slices. - for (ssize_t slice = 0, stop = static_cast(bound_axis_sums.size()); slice < stop; - ++slice) { - // Check whether the axis-wise bound is satisfied for the given hyperslice - switch (bound_axis_info.get_operator(slice)) { - case Equal: - if (bound_axis_sums[slice] == bound_axis_info.get_bound(slice)) continue; - return false; - case LessEqual: - if (bound_axis_sums[slice] <= bound_axis_info.get_bound(slice)) continue; - return false; - case GreaterEqual: - if (bound_axis_sums[slice] >= bound_axis_info.get_bound(slice)) continue; - return false; - default: - throw std::invalid_argument("Invalid axis-wise bound operator"); - } - } - } - return true; -} - void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, const double value_change) const { const auto& bound_axes_info = bound_axes_info_; if (bound_axes_info.size() == 0) return; // No axis-wise bounds to satisfy - // Obtain the multidimensional indices for `index` so we can identify the - // slices `index` lies on per bound axis. - // Possible To Do: We could optimize this get the bound axes indices only. + // Get multidimensional indices for `index` so we can identify the slices + // `index` lies on per bound axis. const std::vector multi_index = unravel_index(index, this->shape()); assert(bound_axes_info.size() <= multi_index.size()); // Get the hyperslice sums of all bound axes. @@ -576,7 +515,7 @@ bool IntegerNode::is_valid(ssize_t index, double value) const { } void IntegerNode::set_value(State& state, ssize_t index, double value) const { - auto ptr = data_ptr(state); + auto ptr = 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); @@ -586,7 +525,7 @@ void IntegerNode::set_value(State& state, ssize_t index, double value) const { if (ptr->set(index, value)) { // Update the bound axis. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); - assert(satisfies_axis_wise_bounds(state)); + assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } } @@ -688,7 +627,7 @@ BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound, std::move(bound_axes)) {} void BinaryNode::flip(State& state, ssize_t i) const { - auto ptr = data_ptr(state); + auto ptr = data_ptr(state); // Variable should not be fixed. assert(lower_bound(i) != upper_bound(i)); // Assert that i is a valid index occurs in ptr->set(). @@ -697,31 +636,33 @@ void BinaryNode::flip(State& state, ssize_t i) const { // If value changed from 0 -> 1, update the bound axis sums by 1. // If value changed from 1 -> 0, update the bound axis sums by -1. update_bound_axis_slice_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); - assert(satisfies_axis_wise_bounds(state)); + assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } } void BinaryNode::set(State& state, ssize_t i) const { + auto ptr = data_ptr(state); // We expect the set to obey the index-wise bounds. assert(upper_bound(i) == 1.0); - // Assert that i is a valid index occurs in data_ptr->set(). + // Assert that i is a valid index occurs in ptr->set(). // set() occurs IFF `value` != buffer[i]. - if (data_ptr(state)->set(i, 1.0)) { + if (ptr->set(i, 1.0)) { // If value changed from 0 -> 1, update the bound axis sums by 1. update_bound_axis_slice_sums(state, i, 1.0); - assert(satisfies_axis_wise_bounds(state)); + assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } } void BinaryNode::unset(State& state, ssize_t i) const { + auto ptr = data_ptr(state); // We expect the set to obey the index-wise bounds. assert(lower_bound(i) == 0.0); - // Assert that i is a valid index occurs in data_ptr->set(). + // Assert that i is a valid index occurs in ptr->set(). // set occurs IFF `value` != buffer[i]. - if (data_ptr(state)->set(i, 0.0)) { + if (ptr->set(i, 0.0)) { // If value changed from 1 -> 0, update the bound axis sums by -1. update_bound_axis_slice_sums(state, i, -1.0); - assert(satisfies_axis_wise_bounds(state)); + assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } } From 7709c696450537cc1c7a4c9b41e7242917553f15 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 28 Jan 2026 16:10:14 -0800 Subject: [PATCH 05/22] NumberNode: Construct state given exactly one axis-wise bound. Defined method to initialize_state() given exactly one axis-wise bound. Fill state with lower bounds and increment until state satisfies axis-wise bounds or determines infeasible. Added appropriate C++ IntegerNode and BinaryNode tests. --- .../dwave-optimization/nodes/numbers.hpp | 4 + dwave/optimization/src/nodes/numbers.cpp | 141 ++++++- tests/cpp/nodes/test_numbers.cpp | 396 +++++++++++++++++- 3 files changed, 521 insertions(+), 20 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 2735c173..c42afdbf 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -97,6 +97,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Initialize the state of the node randomly template void initialize_state(State& state, Generator& rng) const { + if (bound_axes_info_.size() > 0) { + throw std::invalid_argument("Cannot randomly initialize_state with bound axes"); + } + std::vector values; const ssize_t size = this->size(); values.reserve(size); diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 574b8f59..6f30045f 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -25,6 +25,7 @@ #include "_state.hpp" #include "dwave-optimization/array.hpp" +#include "dwave-optimization/common.hpp" namespace dwave::optimization { @@ -91,15 +92,16 @@ double NumberNode::min() const { return min_; } double NumberNode::max() const { return max_; } -std::vector> get_bound_axes_sums( - const std::vector& number_data, const std::vector bound_axes_info, - std::span node_shape, std::span node_strides) { - assert(node_shape.size() == node_strides.size()); - assert(bound_axes_info.size() <= node_shape.size()); +std::vector> get_bound_axes_sums(const NumberNode* node, + const std::vector& number_data) { + std::span node_shape = node->shape(); + const std::vector& bound_axes_info = node->axis_wise_bounds(); + const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); + + assert(num_bound_axes <= node_shape.size()); assert(std::accumulate(node_shape.begin(), node_shape.end(), 1, std::multiplies()) == static_cast(number_data.size())); - const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); // For each bound axis, initialize the sum of the values contained in each // of it's hyperslice to 0. std::vector> bound_axes_sums; @@ -110,18 +112,18 @@ std::vector> get_bound_axes_sums( } // Define a BufferIterator for number_data (contiguous block of doubles) - // given the shape and strides of the NumberNode. - BufferIterator it(number_data.data(), node_shape, node_strides); + // given the shape and strides of NumberNode. + BufferIterator it(number_data.data(), node_shape, node->strides()); // Iterate over number_data. for (; it != std::default_sentinel; ++it) { - // Increment the appropriate slice in each bound axis. - for (ssize_t i = 0; i < num_bound_axes; ++i) { - const ssize_t axis = bound_axes_info[i].axis; + // Increment the appropriate hyperslice along each bound axis. + for (ssize_t bound_axis = 0; bound_axis < num_bound_axes; ++bound_axis) { + const ssize_t axis = bound_axes_info[bound_axis].axis; assert(0 <= axis && axis < it.location().size()); const ssize_t slice = it.location()[axis]; - assert(0 <= slice && slice < bound_axes_sums[i].size()); - bound_axes_sums[i][slice] += *it; + assert(0 <= slice && slice < bound_axes_sums[bound_axis].size()); + bound_axes_sums[bound_axis][slice] += *it; } } @@ -149,7 +151,7 @@ bool satisfies_axis_wise_bounds(const std::vector& bound_axes_inf if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; break; default: - throw std::invalid_argument("Invalid axis-wise bound operator"); + unreachable(); } } } @@ -172,8 +174,7 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat return; } - std::vector> bound_axes_sums = - get_bound_axes_sums(number_data, bound_axes_info_, this->shape(), this->strides()); + std::vector> bound_axes_sums = get_bound_axes_sums(this, number_data); if (!satisfies_axis_wise_bounds(bound_axes_info_, bound_axes_sums)) { throw std::invalid_argument("Initialized values do not satisfy axis-wise bounds."); @@ -183,13 +184,115 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat std::move(bound_axes_sums)); } +void construct_state_given_exactly_one_bound_axis(const NumberNode* node, + std::vector& values) { + const std::span node_shape = node->shape(); + const std::span node_strides = node->strides(); + assert(node_shape.size() == node_strides.size()); + const ssize_t ndim = node_shape.size(); + + // We need to construct a state that satisfies the axis wise bounds. + // First, initialize all elements to their lower bounds. + for (ssize_t i = 0, stop = node->size(); i < stop; ++i) { + values.push_back(node->lower_bound(i)); + } + // Second, determine the hyperslice sums for the bound axis. This could be + // done during the previous loop if we want to improve performance. + assert(node->axis_wise_bounds().size() == 1); + std::vector bound_axis_sums = get_bound_axes_sums(node, values)[0]; + const BoundAxisInfo& bound_axis_info = node->axis_wise_bounds()[0]; + const ssize_t bound_axis = bound_axis_info.axis; + assert(0 <= bound_axis && bound_axis < ndim); + // Iterator to the beginning of `values`. + BufferIterator values_begin(values.data(), ndim, node_shape.data(), + node_strides.data()); + // Offset used to perterb `values_begin` to the first element of the + // hyperslice along the given bound axis. + std::vector offset(ndim, 0); + + // Third, we iterate over each hyperslice and adjust its values until + // it satisfies the axis-wise bounds. + for (ssize_t slice = 0, stop = node_shape[bound_axis]; slice < stop; ++slice) { + // Determine the amount we need to adjust the initialized values by + // to satisfy the axis-wise bounds for the given hyperslice. + double delta = 0; + + switch (bound_axis_info.get_operator(slice)) { + case Equal: + if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) { + throw std::invalid_argument("Axis-wise bounds are infeasible."); + } + delta = bound_axis_info.get_bound(slice) - bound_axis_sums[slice]; + assert(delta >= 0); + // If error was not thrown, either (delta > 0) and (sum < + // bound) or (delta == 0) and (sum == bound). + break; + case LessEqual: + if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) { + throw std::invalid_argument("Axis-wise bounds are infeasible."); + } + // If error was not thrown, then (delta == 0) and (sum <= bound) + break; + case GreaterEqual: + if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) { + delta = bound_axis_info.get_bound(slice) - bound_axis_sums[slice]; + } + assert(delta >= 0); + // Either (delta == 0) and (sum >= bound) or (delta > 0) and + // (sum < bound). + break; + default: + unreachable(); + } + + if (delta == 0) continue; // axis-wise bounds are satisfied for slice. + + // Define iterator to the cannonically least index in the given slice + // along the bound axis. + offset[bound_axis] = slice; + BufferIterator it = values_begin + offset; + + // Iterate over all remaining elements in values. + for (; it != std::default_sentinel_t(); ++it) { + // Only consider values that fall in the slice. + if (it.location()[bound_axis] != slice) continue; + + // Determine the index of `it` from `values_begin` + const ssize_t index = static_cast(it - values_begin); + assert(0 <= index && index < values.size()); + // Determine the amount we can increment the value in the given index. + ssize_t inc = std::min(delta, node->upper_bound(index) - *it); + + if (inc > 0) { // Apply the increment to both `it` and `delta`. + *it += inc; + delta -= inc; + if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. + } + } + + if (delta != 0) { + throw std::invalid_argument("Axis-wise bounds are infeasible."); + } + } +} + void NumberNode::initialize_state(State& state) const { std::vector values; values.reserve(this->size()); - for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { - values.push_back(default_value(i)); + + if (bound_axes_info_.size() == 0) { // No bound axes to consider + for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { + values.push_back(default_value(i)); + } + initialize_state(state, std::move(values)); + return; } - /// Set all to mins + + if (bound_axes_info_.size() != 1) { + throw std::invalid_argument("Cannot initialize state with multiple bound axes."); + } + + construct_state_given_exactly_one_bound_axis(this, values); initialize_state(state, std::move(values)); } diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 598bdb4e..785eb4cc 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -565,7 +565,206 @@ TEST_CASE("BinaryNode") { "Axis-wise bounds are supported for at most one axis."); } - GIVEN("(2x3x4)-BinaryNode with an axis-wise bound on axis: 0") { + GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { + BoundAxisInfo bound_axis{1, std::vector{Equal}, + std::vector{0.1}}; + REQUIRE_THROWS_WITH(graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, + std::nullopt, std::vector{bound_axis}), + "Axis wise bounds for integral number arrays must be intregral."); + } + + GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, + std::vector{5.0, 2.0, 3.0}}; + + // Each hyperslice along axis 0 has size 4. There is no feasible + // assignment to the values in slice 0 (along axis 0) that results in a + // sum equal to 5. + graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, + std::vector{bound_axis}); + + WHEN("We create a state by initialize_state()") { + REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + } + } + + GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{1, std::vector{Equal, GreaterEqual}, + std::vector{5.0, 7.0}}; + + graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, + std::vector{bound_axis}); + + WHEN("We create a state by initialize_state()") { + // Each hyperslice along axis 1 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 1) that results in a + // sum greater than or equal to 7. + REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + } + } + + GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{2, std::vector{Equal, LessEqual}, + std::vector{5.0, -1.0}}; + + graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, + std::vector{bound_axis}); + + WHEN("We create a state by initialize_state()") { + // Each hyperslice along axis 2 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 2) that results in a + // sum less than or equal to -1. + REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + } + } + + GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 0") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, + std::vector{1.0, 2.0, 3.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + // import numpy as np + // a = np.asarray([i for i in range(3*2*2)]).reshape(3, 2, 2) + // print(a[0, :, :].flatten()) + // ... [0 1 2 3] + // print(a[1, :, :].flatten()) + // ... [4 5 6 7] + // print(a[2, :, :].flatten()) + // ... [ 8 9 10 11] + std::vector expected_init{1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0}; + // Cannonically least state that satisfies bounds + // slice 0 slice 1 slice 2 + // 1, 0 0, 0 1, 1 + // 0, 0 0, 0 1, 0 + + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 0, 3})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + + GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 1") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{1, std::vector{LessEqual, GreaterEqual}, + std::vector{1.0, 5.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + // import numpy as np + // a = np.asarray([i for i in range(3*2*2)]).reshape(3, 2, 2) + // print(a[:, 0, :].flatten()) + // ... [0 1 4 5 8 9] + // print(a[:, 1, :].flatten()) + // ... [ 2 3 6 7 10 11] + std::vector expected_init{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0}; + // Cannonically least state that satisfies bounds + // slice 0 slice 1 + // 0, 0 1, 1 + // 0, 0 1, 1 + // 0, 0 1, 0 + + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({0, 5})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + + GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 2") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{2, std::vector{Equal, GreaterEqual}, + std::vector{3.0, 6.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + // import numpy as np + // a = np.asarray([i for i in range(3*2*2)]).reshape(3, 2, 2) + // print(a[:, :, 0].flatten()) + // ... [ 0 2 4 6 8 10] + // print(a[:, :, 1].flatten()) + // ... [ 1 3 5 7 9 11] + std::vector expected_init{1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1}; + // Cannonically least state that satisfies bounds + // slice 0 slice 1 + // 1, 1 1, 1 + // 1, 0 1, 1 + // 0, 0 1, 1 + + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({3, 6})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + + GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, @@ -1208,6 +1407,201 @@ TEST_CASE("IntegerNode") { "Axis wise bounds for integral number arrays must be intregral."); } + GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual}, + std::vector{5.0, -31.0}}; + + graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + WHEN("We create a state by initialize_state()") { + // Each hyperslice along axis 0 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 0) that results in a + // sum less than or equal to -5*6-1 = -31. + REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + } + } + + GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{1, std::vector{GreaterEqual, Equal, Equal}, + std::vector{33.0, 0.0, 0.0}}; + + graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + WHEN("We create a state by initialize_state()") { + // Each hyperslice along axis 1 has size 4. There is no feasible + // assignment to the values in slice 0 (along axis 1) that results in a + // sum greater than or equal to 4*8+1 = 33. + REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + } + } + + GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{2, std::vector{GreaterEqual, Equal}, + std::vector{-1.0, 49.0}}; + + graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + WHEN("We create a state by initialize_state()") { + // Each hyperslice along axis 2 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 2) that results in a + // sum or equal to 6*8+1 = 49 + REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + } + } + + GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{0, std::vector{Equal, GreaterEqual}, + std::vector{-21.0, 9.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + // import numpy as np + // a = np.asarray([i for i in range(2*3*2)]).reshape(2, 3, 2) + // print(a[0, :, :].flatten()) + // ... [0 1 2 3 4 5] + // print(a[1, :, :].flatten()) + // ... [ 6 7 8 9 10 11] + // + // initialize_state() will start with + // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] + // repair slice 0 + // [4, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] + // repair slice 1 + // [4, -5, -5, -5, -5, -5, 8, 8, 8, -5, -5, -5] + std::vector expected_init{4, -5, -5, -5, -5, -5, 8, 8, 8, -5, -5, -5}; + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({-21.0, 9.0})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + + GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{1, std::vector{Equal, GreaterEqual, LessEqual}, + std::vector{0.0, -2.0, 0.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + // import numpy as np + // a = np.asarray([i for i in range(2*3*2)]).reshape(2, 3, 2) + // print(a[:, 0, :].flatten()) + // ... [0 1 6 7] + // print(a[:, 1, :].flatten()) + // ... [2 3 8 9] + // print(a[:, 2, :].flatten()) + // ... [ 4 5 10 11] + // + // initialize_state() will start with + // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] + // repair slice 0 w/ [8, 2, -5, -5] + // [8, 2, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] + // repair slice 1 w/ [8, 0, -5, -5] + // [8, 2, 8, 0, -5, -5, -5, -5, -5, -5, -5, -5] + // no need to repair slice 2 + std::vector expected_init{8, 2, 8, 0, -5, -5, -5, -5, -5, -5, -5, -5}; + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({0.0, -2.0, -20.0})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + + GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { + auto graph = Graph(); + + BoundAxisInfo bound_axis{2, std::vector{Equal, GreaterEqual}, + std::vector{23.0, 14.0}}; + + auto bnode_ptr = graph.emplace_node( + std::initializer_list{2, 3, 2}, -5, 8, + std::vector{bound_axis}); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bound_axis.axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + // import numpy as np + // a = np.asarray([i for i in range(2*3*2)]).reshape(2, 3, 2) + // print(a[:, :, 0].flatten()) + // ... [ 0 2 4 6 8 10] + // print(a[:, :, 0].flatten()) + // ... [ 1 3 5 7 9 11] + // + // initialize_state() will start with + // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] + // repair slice 0 w/ [8, 8, 8, 8, -4, -5] + // [8, -5, 8, -5, 8, -5, 8, -5, -4, -5, -5, -5] + // repair slice 0 w/ [8, 8, 8, 0, -5, -5] + // [8, 8, 8, 8, 8, 8, 8, 0, -4, -5, -5, -5] + std::vector expected_init{8, 8, 8, 8, 8, 8, 8, 0, -4, -5, -5, -5}; + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); + CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({23.0, 14.0})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { auto graph = Graph(); From c56a8d424ff6d682b1c33a568821f0ce8b6fca29 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Thu, 29 Jan 2026 15:35:47 -0800 Subject: [PATCH 06/22] Improve NumberNode bound axes Made BoundAxisInfo and BoundAxisOperators members of NumberNode. Updated all C++ tests. Optimized BufferIterator use in initialize_state(). --- .../dwave-optimization/nodes/numbers.hpp | 57 ++- dwave/optimization/src/nodes/numbers.cpp | 133 +++--- tests/cpp/nodes/test_numbers.cpp | 412 +++++++++++------- 3 files changed, 349 insertions(+), 253 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index c42afdbf..bf96fdad 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -25,38 +25,37 @@ namespace dwave::optimization { -/// Allowable axis-wise bound operators. -enum BoundAxisOperator { Equal, LessEqual, GreaterEqual }; - -/// Class for stateless axis-wise bound information. Given an `axis`, define -/// constraints on the sum of the values in each slice along `axis`. -/// Constraints can be defined for ALL slices along `axis` or PER slice along -/// `axis`. Allowable operators are defined by `BoundAxisOperator`. -class BoundAxisInfo { - public: - /// To reduce the # of `IntegerNode` and `BinaryNode` constructors, we - /// allow only one constructor. - BoundAxisInfo(ssize_t axis, std::vector axis_operators, - std::vector axis_bounds); - /// The bound axis - const ssize_t axis; - /// Operator for ALL axis slices (vector has length one) or operator*s* PER - /// slice (length of vector is equal to the number of slices). - const std::vector operators; - /// Bound for ALL axis slices (vector has length one) or bound*s* PER slice - /// (length of vector is equal to the number of slices). - const std::vector bounds; - - /// Obtain the bound associated with a given slice along bound axis. - double get_bound(const ssize_t slice) const; - - /// Obtain the operator associated with a given slice along bound axis. - BoundAxisOperator get_operator(const ssize_t slice) const; -}; - /// A contiguous block of numbers. class NumberNode : public ArrayOutputMixin, public DecisionNode { public: + /// Allowable axis-wise bound operators. + enum BoundAxisOperator { Equal, LessEqual, GreaterEqual }; + + /// Struct for stateless axis-wise bound information. Given an `axis`, define + /// constraints on the sum of the values in each slice along `axis`. + /// Constraints can be defined for ALL slices along `axis` or PER slice along + /// `axis`. Allowable operators are defined by `BoundAxisOperator`. + struct BoundAxisInfo { + /// To reduce the # of `IntegerNode` and `BinaryNode` constructors, we + /// allow only one constructor. + BoundAxisInfo(ssize_t axis, std::vector axis_operators, + std::vector axis_bounds); + /// The bound axis + const ssize_t axis; + /// Operator for ALL axis slices (vector has length one) or operator*s* PER + /// slice (length of vector is equal to the number of slices). + const std::vector operators; + /// Bound for ALL axis slices (vector has length one) or bound*s* PER slice + /// (length of vector is equal to the number of slices). + const std::vector bounds; + + /// Obtain the bound associated with a given slice along bound axis. + double get_bound(const ssize_t slice) const; + + /// Obtain the operator associated with a given slice along bound axis. + BoundAxisOperator get_operator(const ssize_t slice) const; + }; + NumberNode() = delete; // Overloads needed by the Array ABC ************************************** diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 6f30045f..cb6108d7 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -29,8 +29,9 @@ namespace dwave::optimization { -BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector axis_operators, - std::vector axis_bounds) +NumberNode::BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, + std::vector axis_operators, + std::vector axis_bounds) : axis(bound_axis), operators(std::move(axis_operators)), bounds(std::move(axis_bounds)) { const ssize_t num_operators = static_cast(operators.size()); const ssize_t num_bounds = static_cast(bounds.size()); @@ -50,14 +51,14 @@ BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector } } -double BoundAxisInfo::get_bound(const ssize_t slice) const { +double NumberNode::BoundAxisInfo::get_bound(const ssize_t slice) const { assert(0 <= slice); if (bounds.size() == 0) return bounds[0]; assert(slice < static_cast(bounds.size())); return bounds[slice]; } -BoundAxisOperator BoundAxisInfo::get_operator(const ssize_t slice) const { +NumberNode::BoundAxisOperator NumberNode::BoundAxisInfo::get_operator(const ssize_t slice) const { assert(0 <= slice); if (operators.size() == 0) return operators[0]; assert(slice < static_cast(operators.size())); @@ -95,7 +96,7 @@ double NumberNode::max() const { return max_; } std::vector> get_bound_axes_sums(const NumberNode* node, const std::vector& number_data) { std::span node_shape = node->shape(); - const std::vector& bound_axes_info = node->axis_wise_bounds(); + const std::vector& bound_axes_info = node->axis_wise_bounds(); const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); assert(num_bound_axes <= node_shape.size()); @@ -106,7 +107,7 @@ std::vector> get_bound_axes_sums(const NumberNode* node, // of it's hyperslice to 0. std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); - for (const BoundAxisInfo& axis_info : bound_axes_info) { + for (const NumberNode::BoundAxisInfo& axis_info : bound_axes_info) { assert(0 <= axis_info.axis && axis_info.axis < static_cast(node_shape.size())); bound_axes_sums.emplace_back(node_shape[axis_info.axis], 0.0); } @@ -130,24 +131,24 @@ std::vector> get_bound_axes_sums(const NumberNode* node, return bound_axes_sums; } -bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, +bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, const std::vector>& bound_axes_sums) { assert(bound_axes_info.size() == bound_axes_sums.size()); // Check that each hyperslice satisfies the axis-wise bounds. for (ssize_t i = 0, stop_i = static_cast(bound_axes_info.size()); i < stop_i; ++i) { const std::vector& bound_axis_sums = bound_axes_sums[i]; - const BoundAxisInfo& bound_axis_info = bound_axes_info[i]; + const NumberNode::BoundAxisInfo& bound_axis_info = bound_axes_info[i]; for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); slice < stop_slice; ++slice) { switch (bound_axis_info.get_operator(slice)) { - case Equal: + case NumberNode::Equal: if (bound_axis_sums[slice] != bound_axis_info.get_bound(slice)) return false; break; - case LessEqual: + case NumberNode::LessEqual: if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) return false; break; - case GreaterEqual: + case NumberNode::GreaterEqual: if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; break; default: @@ -184,11 +185,42 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat std::move(bound_axes_sums)); } +std::vector reorder_span(const std::span span, const ssize_t axis) { + std::vector output; + const ssize_t ndim = span.size(); + output.reserve(ndim); + output.emplace_back(span[axis]); + for (ssize_t i = 0; i < ndim; ++i) { + if (i == axis) continue; + output.emplace_back(span[i]); + } + return output; +} + +double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, + const NumberNode::BoundAxisOperator op, const double bound) { + switch (op) { + case NumberNode::Equal: + if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); + // If error was not thrown, return amount needed to satisfy bound. + return bound - sum; + case NumberNode::LessEqual: + if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); + // If error was not thrown, sum satisfies bound. + return 0.0; + case NumberNode::GreaterEqual: + // If sum is less than bound, return the amount needed to equal it. + if (sum < bound) return bound - sum; + // Otherwise, sum satisfies bound. + return 0.0; + default: + unreachable(); + } +} + void construct_state_given_exactly_one_bound_axis(const NumberNode* node, std::vector& values) { const std::span node_shape = node->shape(); - const std::span node_strides = node->strides(); - assert(node_shape.size() == node_strides.size()); const ssize_t ndim = node_shape.size(); // We need to construct a state that satisfies the axis wise bounds. @@ -200,12 +232,20 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, // done during the previous loop if we want to improve performance. assert(node->axis_wise_bounds().size() == 1); std::vector bound_axis_sums = get_bound_axes_sums(node, values)[0]; - const BoundAxisInfo& bound_axis_info = node->axis_wise_bounds()[0]; + + const NumberNode::BoundAxisInfo& bound_axis_info = node->axis_wise_bounds()[0]; const ssize_t bound_axis = bound_axis_info.axis; assert(0 <= bound_axis && bound_axis < ndim); + // Iterator to the beginning of `values`. - BufferIterator values_begin(values.data(), ndim, node_shape.data(), - node_strides.data()); + std::vector slice_shape = reorder_span(node_shape, bound_axis); + std::vector slice_strides = reorder_span(node->strides(), bound_axis); + BufferIterator values_begin(values.data(), ndim, slice_shape.data(), + slice_strides.data()); + std::vector one_more(ndim, 0); + one_more[0] = 1; + auto values_next = values_begin + one_more; + // Offset used to perterb `values_begin` to the first element of the // hyperslice along the given bound axis. std::vector offset(ndim, 0); @@ -215,47 +255,19 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, for (ssize_t slice = 0, stop = node_shape[bound_axis]; slice < stop; ++slice) { // Determine the amount we need to adjust the initialized values by // to satisfy the axis-wise bounds for the given hyperslice. - double delta = 0; - - switch (bound_axis_info.get_operator(slice)) { - case Equal: - if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) { - throw std::invalid_argument("Axis-wise bounds are infeasible."); - } - delta = bound_axis_info.get_bound(slice) - bound_axis_sums[slice]; - assert(delta >= 0); - // If error was not thrown, either (delta > 0) and (sum < - // bound) or (delta == 0) and (sum == bound). - break; - case LessEqual: - if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) { - throw std::invalid_argument("Axis-wise bounds are infeasible."); - } - // If error was not thrown, then (delta == 0) and (sum <= bound) - break; - case GreaterEqual: - if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) { - delta = bound_axis_info.get_bound(slice) - bound_axis_sums[slice]; - } - assert(delta >= 0); - // Either (delta == 0) and (sum >= bound) or (delta > 0) and - // (sum < bound). - break; - default: - unreachable(); - } + double delta = compute_bound_axis_slice_delta(slice, bound_axis_sums[slice], + bound_axis_info.get_operator(slice), + bound_axis_info.get_bound(slice)); + assert(delta >= 0); if (delta == 0) continue; // axis-wise bounds are satisfied for slice. + offset[0] = slice; // Define iterator to the cannonically least index in the given slice // along the bound axis. - offset[bound_axis] = slice; - BufferIterator it = values_begin + offset; - - // Iterate over all remaining elements in values. - for (; it != std::default_sentinel_t(); ++it) { + for (auto it = values_begin + offset, it_end = values_next + offset; it != it_end; ++it) { // Only consider values that fall in the slice. - if (it.location()[bound_axis] != slice) continue; + assert(it.location()[0] == slice); // Determine the index of `it` from `values_begin` const ssize_t index = static_cast(it - values_begin); @@ -266,13 +278,11 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, if (inc > 0) { // Apply the increment to both `it` and `delta`. *it += inc; delta -= inc; - if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. + if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. } } - if (delta != 0) { - throw std::invalid_argument("Axis-wise bounds are infeasible."); - } + if (delta != 0) throw std::invalid_argument("Infeasible axis-wise bounds."); } } @@ -379,7 +389,9 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c } } -const std::vector& NumberNode::axis_wise_bounds() const { return bound_axes_info_; } +const std::vector& NumberNode::axis_wise_bounds() const { + return bound_axes_info_; +} const std::vector>& NumberNode::bound_axis_sums(State& state) const { return data_ptr(state)->bound_axes_sums; @@ -426,7 +438,7 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& } /// Check the user defined axis-wise bounds for NumberNode -void check_axis_wise_bounds(const std::vector& bound_axes_info, +void check_axis_wise_bounds(const std::vector& bound_axes_info, const std::span shape) { if (bound_axes_info.size() == 0) return; // No bound axes to check. @@ -434,7 +446,7 @@ void check_axis_wise_bounds(const std::vector& bound_axes_info, std::vector axis_bound(shape.size(), false); // For each set of bound axis data - for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { + for (const NumberNode::BoundAxisInfo& bound_axis_info : bound_axes_info) { const ssize_t axis = bound_axis_info.axis; if (axis < 0 || axis >= static_cast(shape.size())) { @@ -524,10 +536,11 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, // Integer Node *************************************************************** /// Check the user defined axis-wise bounds for IntegerNode -void check_integrality_of_axis_wise_bounds(const std::vector& bound_axes_info) { +void check_integrality_of_axis_wise_bounds( + const std::vector& bound_axes_info) { if (bound_axes_info.size() == 0) return; // No bound axes to check. - for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { + for (const NumberNode::BoundAxisInfo& bound_axis_info : bound_axes_info) { for (const double& bound : bound_axis_info.bounds) { if (bound != std::round(bound)) { throw std::invalid_argument( diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 785eb4cc..85c7e157 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -29,42 +29,54 @@ namespace dwave::optimization { TEST_CASE("BoundAxisInfo") { GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { REQUIRE_THROWS_WITH( - BoundAxisInfo(0, std::vector{}, std::vector{1.0}), + NumberNode::BoundAxisInfo(0, std::vector{}, + std::vector{1.0}), "Bad axis-wise bounds for axis: 0, `operators` and `bounds` must each have " "non-zero size."); } GIVEN("BoundAxisInfo(axis = 0, operators = {<=}, bounds = {})") { REQUIRE_THROWS_WITH( - BoundAxisInfo(0, std::vector{LessEqual}, std::vector{}), + NumberNode::BoundAxisInfo(0, + std::vector{ + NumberNode::NumberNode::LessEqual}, + std::vector{}), "Bad axis-wise bounds for axis: 0, `operators` and `bounds` must each have " "non-zero size."); } GIVEN("BoundAxisInfo(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { REQUIRE_THROWS_WITH( - BoundAxisInfo(1, std::vector{LessEqual, Equal, Equal}, - std::vector{2.0, 1.0}), + NumberNode::BoundAxisInfo( + 1, + std::vector{ + NumberNode::LessEqual, NumberNode::Equal, NumberNode::Equal}, + std::vector{2.0, 1.0}), "Bad axis-wise bounds for axis: 1, `operators` and `bounds` should have same size " "if neither has size 1."); } GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0})") { - BoundAxisInfo bound_axis(2, std::vector{Equal}, - std::vector{1.0}); + NumberNode::BoundAxisInfo bound_axis( + 2, std::vector{NumberNode::Equal}, + std::vector{1.0}); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); - CHECK_THAT(bound_axis.operators, RangeEquals({Equal})); + CHECK_THAT(bound_axis.operators, RangeEquals({NumberNode::Equal})); CHECK_THAT(bound_axis.bounds, RangeEquals({1.0})); } } GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { - BoundAxisInfo bound_axis(2, std::vector{Equal, LessEqual, GreaterEqual}, - std::vector{1.0, 2.0, 3.0}); + NumberNode::BoundAxisInfo bound_axis( + 2, + std::vector{NumberNode::Equal, NumberNode::LessEqual, + NumberNode::GreaterEqual}, + std::vector{1.0, 2.0, 3.0}); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); - CHECK_THAT(bound_axis.operators, RangeEquals({Equal, LessEqual, GreaterEqual})); + CHECK_THAT(bound_axis.operators, RangeEquals({NumberNode::Equal, NumberNode::LessEqual, + NumberNode::GreaterEqual})); CHECK_THAT(bound_axis.bounds, RangeEquals({1.0, 2.0, 3.0})); } } @@ -486,161 +498,189 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { - BoundAxisInfo bound_axis{-1, std::vector{Equal}, - std::vector{1.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid bound axis: -1. Note, negative indexing is not supported for " - "axis-wise bounds."); + NumberNode::BoundAxisInfo bound_axis{ + -1, std::vector{NumberNode::Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid bound axis: -1. Note, negative indexing is not supported for " + "axis-wise bounds."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { - BoundAxisInfo bound_axis{2, std::vector{Equal}, - std::vector{1.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid bound axis: 2. Note, negative indexing is not supported for " - "axis-wise bounds."); + NumberNode::BoundAxisInfo bound_axis{ + 2, std::vector{NumberNode::Equal}, + std::vector{1.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid bound axis: 2. Note, negative indexing is not supported for " + "axis-wise bounds."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many operators.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal, Equal, Equal}, - std::vector{1.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, + std::vector{NumberNode::LessEqual, NumberNode::Equal, + NumberNode::Equal, NumberNode::Equal}, + std::vector{1.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), + std::vector{bound_axis}), "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal}, - std::vector{1.0}}; + NumberNode::BoundAxisInfo bound_axis{1, + std::vector{ + NumberNode::LessEqual, NumberNode::Equal}, + std::vector{1.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), + std::vector{bound_axis}), "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many bounds.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{1.0, 2.0, 3.0, 4.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::LessEqual}, + std::vector{1.0, 2.0, 3.0, 4.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{1.0, 2.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::LessEqual}, + std::vector{1.0, 2.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); } GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { - BoundAxisInfo bound_axis{1, std::vector{Equal}, - std::vector{1.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::Equal}, + std::vector{1.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis, bound_axis}), + std::vector{bound_axis, bound_axis}), "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { - BoundAxisInfo bound_axis_0{0, std::vector{LessEqual}, - std::vector{1.0}}; - BoundAxisInfo bound_axis_1{1, std::vector{LessEqual}, - std::vector{1.0}}; + NumberNode::BoundAxisInfo bound_axis_0{ + 0, std::vector{NumberNode::LessEqual}, + std::vector{1.0}}; + NumberNode::BoundAxisInfo bound_axis_1{ + 1, std::vector{NumberNode::LessEqual}, + std::vector{1.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis_0, bound_axis_1}), + std::vector{bound_axis_0, bound_axis_1}), "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - BoundAxisInfo bound_axis{1, std::vector{Equal}, - std::vector{0.1}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Axis wise bounds for integral number arrays must be intregral."); + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::Equal}, + std::vector{0.1}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Axis wise bounds for integral number arrays must be intregral."); } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, - std::vector{5.0, 2.0, 3.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 0, + std::vector{NumberNode::Equal, NumberNode::LessEqual, + NumberNode::GreaterEqual}, + std::vector{5.0, 2.0, 3.0}}; // Each hyperslice along axis 0 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 0) that results in a // sum equal to 5. - graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, - std::vector{bound_axis}); + graph.emplace_node( + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, + std::vector{bound_axis}); WHEN("We create a state by initialize_state()") { - REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); } } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - BoundAxisInfo bound_axis{1, std::vector{Equal, GreaterEqual}, - std::vector{5.0, 7.0}}; + NumberNode::BoundAxisInfo bound_axis{1, + std::vector{ + NumberNode::Equal, NumberNode::GreaterEqual}, + std::vector{5.0, 7.0}}; - graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, - std::vector{bound_axis}); + graph.emplace_node( + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, + std::vector{bound_axis}); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 1 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 1) that results in a // sum greater than or equal to 7. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); } } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - BoundAxisInfo bound_axis{2, std::vector{Equal, LessEqual}, - std::vector{5.0, -1.0}}; + NumberNode::BoundAxisInfo bound_axis{2, + std::vector{ + NumberNode::Equal, NumberNode::LessEqual}, + std::vector{5.0, -1.0}}; - graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, - std::vector{bound_axis}); + graph.emplace_node( + std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, + std::vector{bound_axis}); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 2 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 2) that results in a // sum less than or equal to -1. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); } } GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, - std::vector{1.0, 2.0, 3.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 0, + std::vector{NumberNode::Equal, NumberNode::LessEqual, + NumberNode::GreaterEqual}, + std::vector{1.0, 2.0, 3.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -677,16 +717,19 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - BoundAxisInfo bound_axis{1, std::vector{LessEqual, GreaterEqual}, - std::vector{1.0, 5.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, + std::vector{NumberNode::LessEqual, + NumberNode::GreaterEqual}, + std::vector{1.0, 5.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -722,16 +765,18 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - BoundAxisInfo bound_axis{2, std::vector{Equal, GreaterEqual}, - std::vector{3.0, 6.0}}; + NumberNode::BoundAxisInfo bound_axis{2, + std::vector{ + NumberNode::Equal, NumberNode::GreaterEqual}, + std::vector{3.0, 6.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -767,16 +812,19 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); - BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, GreaterEqual}, - std::vector{1.0, 2.0, 3.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 0, + std::vector{NumberNode::Equal, NumberNode::LessEqual, + NumberNode::GreaterEqual}, + std::vector{1.0, 2.0, 3.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1319,161 +1367,188 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { - BoundAxisInfo bound_axis{-2, std::vector{Equal}, - std::vector{20.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid bound axis: -2. Note, negative indexing is not supported for " - "axis-wise bounds."); + NumberNode::BoundAxisInfo bound_axis{ + -2, std::vector{NumberNode::Equal}, + std::vector{20.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid bound axis: -2. Note, negative indexing is not supported for " + "axis-wise bounds."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { - BoundAxisInfo bound_axis{3, std::vector{Equal}, - std::vector{10.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid bound axis: 3. Note, negative indexing is not supported for " - "axis-wise bounds."); + NumberNode::BoundAxisInfo bound_axis{ + 3, std::vector{NumberNode::Equal}, + std::vector{10.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid bound axis: 3. Note, negative indexing is not supported for " + "axis-wise bounds."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many operators.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal, Equal, Equal}, - std::vector{-10.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, + std::vector{NumberNode::LessEqual, NumberNode::Equal, + NumberNode::Equal, NumberNode::Equal}, + std::vector{-10.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), + std::vector{bound_axis}), "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual, Equal}, - std::vector{-11.0}}; + NumberNode::BoundAxisInfo bound_axis{1, + std::vector{ + NumberNode::LessEqual, NumberNode::Equal}, + std::vector{-11.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), + std::vector{bound_axis}), "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many bounds.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{-10.0, 20.0, 30.0, 40.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::LessEqual}, + std::vector{-10.0, 20.0, 30.0, 40.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{111.0, -223.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::LessEqual}, + std::vector{111.0, -223.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); } GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { - BoundAxisInfo bound_axis{1, std::vector{Equal}, - std::vector{100.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, std::vector{NumberNode::Equal}, + std::vector{100.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis, bound_axis}), + std::vector{bound_axis, bound_axis}), "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { - BoundAxisInfo bound_axis_0{0, std::vector{LessEqual}, - std::vector{11.0}}; - BoundAxisInfo bound_axis_1{1, std::vector{LessEqual}, - std::vector{12.0}}; + NumberNode::BoundAxisInfo bound_axis_0{ + 0, std::vector{NumberNode::LessEqual}, + std::vector{11.0}}; + NumberNode::BoundAxisInfo bound_axis_1{ + 1, std::vector{NumberNode::LessEqual}, + std::vector{12.0}}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis_0, bound_axis_1}), + std::vector{bound_axis_0, bound_axis_1}), "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - BoundAxisInfo bound_axis{2, std::vector{LessEqual}, - std::vector{11.0, 12.0001, 0.0, 0.0}}; - REQUIRE_THROWS_WITH(graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, - std::nullopt, std::vector{bound_axis}), - "Axis wise bounds for integral number arrays must be intregral."); + NumberNode::BoundAxisInfo bound_axis{ + 2, std::vector{NumberNode::LessEqual}, + std::vector{11.0, 12.0001, 0.0, 0.0}}; + REQUIRE_THROWS_WITH( + graph.emplace_node( + std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, + std::vector{bound_axis}), + "Axis wise bounds for integral number arrays must be intregral."); } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual}, - std::vector{5.0, -31.0}}; + NumberNode::BoundAxisInfo bound_axis{0, + std::vector{ + NumberNode::Equal, NumberNode::LessEqual}, + std::vector{5.0, -31.0}}; graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 0 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 0) that results in a // sum less than or equal to -5*6-1 = -31. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); } } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - BoundAxisInfo bound_axis{1, std::vector{GreaterEqual, Equal, Equal}, - std::vector{33.0, 0.0, 0.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, + std::vector{NumberNode::GreaterEqual, + NumberNode::Equal, NumberNode::Equal}, + std::vector{33.0, 0.0, 0.0}}; graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 1 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 1) that results in a // sum greater than or equal to 4*8+1 = 33. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); } } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - BoundAxisInfo bound_axis{2, std::vector{GreaterEqual, Equal}, - std::vector{-1.0, 49.0}}; + NumberNode::BoundAxisInfo bound_axis{2, + std::vector{ + NumberNode::GreaterEqual, NumberNode::Equal}, + std::vector{-1.0, 49.0}}; graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 2 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 2) that results in a // sum or equal to 6*8+1 = 49 - REQUIRE_THROWS_WITH(graph.initialize_state(), "Axis-wise bounds are infeasible."); + REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); } } GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - BoundAxisInfo bound_axis{0, std::vector{Equal, GreaterEqual}, - std::vector{-21.0, 9.0}}; + NumberNode::BoundAxisInfo bound_axis{0, + std::vector{ + NumberNode::Equal, NumberNode::GreaterEqual}, + std::vector{-21.0, 9.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1510,16 +1585,19 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - BoundAxisInfo bound_axis{1, std::vector{Equal, GreaterEqual, LessEqual}, - std::vector{0.0, -2.0, 0.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, + std::vector{ + NumberNode::Equal, NumberNode::GreaterEqual, NumberNode::LessEqual}, + std::vector{0.0, -2.0, 0.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1559,16 +1637,18 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - BoundAxisInfo bound_axis{2, std::vector{Equal, GreaterEqual}, - std::vector{23.0, 14.0}}; + NumberNode::BoundAxisInfo bound_axis{2, + std::vector{ + NumberNode::Equal, NumberNode::GreaterEqual}, + std::vector{23.0, 14.0}}; auto bnode_ptr = graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axis.axis == bnode_bound_axis.axis); CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1605,16 +1685,20 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { auto graph = Graph(); - BoundAxisInfo bound_axis{1, std::vector{Equal, LessEqual, GreaterEqual}, - std::vector{11.0, 2.0, 5.0}}; + NumberNode::BoundAxisInfo bound_axis{ + 1, + std::vector{NumberNode::Equal, NumberNode::LessEqual, + NumberNode::GreaterEqual}, + std::vector{11.0, 2.0, 5.0}}; auto inode_ptr = graph.emplace_node( std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector{bound_axis}); THEN("Axis wise bound is correct") { CHECK(inode_ptr->axis_wise_bounds().size() == 1); - const BoundAxisInfo inode_bound_axis_ptr = inode_ptr->axis_wise_bounds().data()[0]; + const NumberNode::BoundAxisInfo inode_bound_axis_ptr = + inode_ptr->axis_wise_bounds().data()[0]; CHECK(bound_axis.axis == inode_bound_axis_ptr.axis); CHECK_THAT(bound_axis.operators, RangeEquals(inode_bound_axis_ptr.operators)); CHECK_THAT(bound_axis.bounds, RangeEquals(inode_bound_axis_ptr.bounds)); From 8bb97b714ac0be9b07c0905e5cf73dae7eafab65 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 30 Jan 2026 14:40:01 -0800 Subject: [PATCH 07/22] Clean up axis-wise bound NumberNode C++ code Improved comments. Improved methods. Cleaned up C++ tests. Added static_casts where necessary for CircleCI. --- .../dwave-optimization/nodes/numbers.hpp | 14 +- dwave/optimization/src/nodes/numbers.cpp | 195 +++--- tests/cpp/nodes/test_numbers.cpp | 593 ++++++++---------- 3 files changed, 371 insertions(+), 431 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index bf96fdad..d503298e 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -49,10 +49,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// (length of vector is equal to the number of slices). const std::vector bounds; - /// Obtain the bound associated with a given slice along bound axis. + /// Obtain the bound associated with a given slice along `axis`. double get_bound(const ssize_t slice) const; - /// Obtain the operator associated with a given slice along bound axis. + /// Obtain the operator associated with a given slice along `axis`. BoundAxisOperator get_operator(const ssize_t slice) const; }; @@ -96,6 +96,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Initialize the state of the node randomly template void initialize_state(State& state, Generator& rng) const { + // Currently, we do not support random node Initialization with + // axis wise bounds. if (bound_axes_info_.size() > 0) { throw std::invalid_argument("Cannot randomly initialize_state with bound axes"); } @@ -138,10 +140,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // in a given index. void clip_and_set_value(State& state, ssize_t index, double value) const; - /// Return pointer to the vector of axis-wise bounds + /// Return vector of axis-wise bounds. const std::vector& axis_wise_bounds() const; - // Return a pointer to the vector containing the bound axis sums + /// Return vector containing the bound axis sums in a given state. const std::vector>& bound_axis_sums(State& state) const; protected: @@ -155,8 +157,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// Default value in a given index. virtual double default_value(ssize_t index) const = 0; - /// Update the running bound axis sums where `index` is changed by - /// `value_change` in a given state. + /// Update the running bound axis sums where the value stored at `index` is + /// changed by `value_change` in a given state. void update_bound_axis_slice_sums(State& state, const ssize_t index, const double value_change) const; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index cb6108d7..bb32c389 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -33,21 +33,18 @@ NumberNode::BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, std::vector axis_operators, std::vector axis_bounds) : axis(bound_axis), operators(std::move(axis_operators)), bounds(std::move(axis_bounds)) { - const ssize_t num_operators = static_cast(operators.size()); - const ssize_t num_bounds = static_cast(bounds.size()); + const size_t num_operators = operators.size(); + const size_t num_bounds = bounds.size(); - // Null `operators` and `bounds` are not accepted. if ((num_operators == 0) || (num_bounds == 0)) { - throw std::invalid_argument("Bad axis-wise bounds for axis: " + std::to_string(axis) + - ", `operators` and `bounds` must each have non-zero size."); + throw std::invalid_argument("Axis-wise `operators` and `bounds` must have non-zero size."); } - // If `operators` and `bounds` are defined PER hyperslice along `axis`, - // they must have the same size. + // If `operators` and `bounds` are both defined PER hyperslice along + // `axis`, they must have the same size. if ((num_operators > 1) && (num_bounds > 1) && (num_bounds != num_operators)) { throw std::invalid_argument( - "Bad axis-wise bounds for axis: " + std::to_string(axis) + - ", `operators` and `bounds` should have same size if neither has size 1."); + "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); } } @@ -93,18 +90,21 @@ double NumberNode::min() const { return min_; } double NumberNode::max() const { return max_; } +/// Given a NumberNode and an assingnment of it's variables (number_data), +/// compute and return a vector containing the sum of the values within each +/// hyperslice along each bound axis. std::vector> get_bound_axes_sums(const NumberNode* node, const std::vector& number_data) { std::span node_shape = node->shape(); - const std::vector& bound_axes_info = node->axis_wise_bounds(); + const auto& bound_axes_info = node->axis_wise_bounds(); const ssize_t num_bound_axes = static_cast(bound_axes_info.size()); - - assert(num_bound_axes <= node_shape.size()); + assert(num_bound_axes <= static_cast(node_shape.size())); assert(std::accumulate(node_shape.begin(), node_shape.end(), 1, std::multiplies()) == static_cast(number_data.size())); // For each bound axis, initialize the sum of the values contained in each - // of it's hyperslice to 0. + // of it's hyperslice to 0. Define bound_axes_sums[i][j] = "sum of the + // values within the jth hyperslice along the ith bound axis" std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); for (const NumberNode::BoundAxisInfo& axis_info : bound_axes_info) { @@ -112,18 +112,16 @@ std::vector> get_bound_axes_sums(const NumberNode* node, bound_axes_sums.emplace_back(node_shape[axis_info.axis], 0.0); } - // Define a BufferIterator for number_data (contiguous block of doubles) - // given the shape and strides of NumberNode. - BufferIterator it(number_data.data(), node_shape, node->strides()); - - // Iterate over number_data. - for (; it != std::default_sentinel; ++it) { + // Define a BufferIterator for `number_data` given the shape and strides of + // NumberNode and iterate over it. + for (BufferIterator it(number_data.data(), node_shape, node->strides()); + it != std::default_sentinel; ++it) { // Increment the appropriate hyperslice along each bound axis. for (ssize_t bound_axis = 0; bound_axis < num_bound_axes; ++bound_axis) { const ssize_t axis = bound_axes_info[bound_axis].axis; - assert(0 <= axis && axis < it.location().size()); + assert(0 <= axis && axis < static_cast(it.location().size())); const ssize_t slice = it.location()[axis]; - assert(0 <= slice && slice < bound_axes_sums[bound_axis].size()); + assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); bound_axes_sums[bound_axis][slice] += *it; } } @@ -131,13 +129,15 @@ std::vector> get_bound_axes_sums(const NumberNode* node, return bound_axes_sums; } +/// Determine whether the sum of the values within each hyperslice along +/// each bound axis satisfies the axis-wise bounds. bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, const std::vector>& bound_axes_sums) { assert(bound_axes_info.size() == bound_axes_sums.size()); // Check that each hyperslice satisfies the axis-wise bounds. for (ssize_t i = 0, stop_i = static_cast(bound_axes_info.size()); i < stop_i; ++i) { - const std::vector& bound_axis_sums = bound_axes_sums[i]; - const NumberNode::BoundAxisInfo& bound_axis_info = bound_axes_info[i]; + const auto& bound_axis_info = bound_axes_info[i]; + const auto& bound_axis_sums = bound_axes_sums[i]; for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); slice < stop_slice; ++slice) { @@ -175,6 +175,8 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat return; } + // Given the assingnment to NumberNode, `number_data`, get the sum of the + // values within each hyperslice along each bound axis. std::vector> bound_axes_sums = get_bound_axes_sums(this, number_data); if (!satisfies_axis_wise_bounds(bound_axes_info_, bound_axes_sums)) { @@ -185,18 +187,28 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat std::move(bound_axes_sums)); } -std::vector reorder_span(const std::span span, const ssize_t axis) { - std::vector output; +/// Given a `span` (typically containing strides or shape), we reorder the +/// values of the span such that the given `axis` is moved to the 0th index. +std::vector reorder_to_move_along_axis(const std::span span, + const ssize_t axis) { const ssize_t ndim = span.size(); + std::vector output; output.reserve(ndim); output.emplace_back(span[axis]); + for (ssize_t i = 0; i < ndim; ++i) { - if (i == axis) continue; - output.emplace_back(span[i]); + if (i != axis) output.emplace_back(span[i]); } return output; } +/// Given a `slice` along a bound axis in a NumberNode where the sum of it's +/// values are given by `sum`, determine the non-negative amount `delta` +/// needed to be added to `sum` to satisfy the expression: (sum+delta) op bound +/// e.g. Given (sum, op, bound) := (10, ==, 12), delta = 2 +/// e.g. Given (sum, op, bound) := (10, <=, 12), delta = 0 +/// e.g. Given (sum, op, bound) := (10, >=, 12), delta = 2 +/// Throws an error if `delta` is negative (corresponding with an infeasible axis-wise bound); double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, const NumberNode::BoundAxisOperator op, const double bound) { switch (op) { @@ -218,65 +230,74 @@ double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, } } +/// Given a NumberNod and exactly one axis-wise bound defined for NumberNode, +/// assign values to `values` (in-place) to satisfy the axis-wise bound. This method +/// 1) Initially sets `values[i] = lower_bound(i)` for all i. +/// 2) Incremements the values within each hyperslice until they satisfy +/// the axis-wise bound (should this be possible). void construct_state_given_exactly_one_bound_axis(const NumberNode* node, std::vector& values) { const std::span node_shape = node->shape(); const ssize_t ndim = node_shape.size(); - // We need to construct a state that satisfies the axis wise bounds. - // First, initialize all elements to their lower bounds. + // 1) Initialize all elements to their lower bounds. for (ssize_t i = 0, stop = node->size(); i < stop; ++i) { values.push_back(node->lower_bound(i)); } - // Second, determine the hyperslice sums for the bound axis. This could be + // 2) Determine the hyperslice sums for the bound axis. This could be // done during the previous loop if we want to improve performance. assert(node->axis_wise_bounds().size() == 1); - std::vector bound_axis_sums = get_bound_axes_sums(node, values)[0]; - + const std::vector bound_axis_sums = get_bound_axes_sums(node, values)[0]; + // Obtain the axis-wise bound const NumberNode::BoundAxisInfo& bound_axis_info = node->axis_wise_bounds()[0]; const ssize_t bound_axis = bound_axis_info.axis; assert(0 <= bound_axis && bound_axis < ndim); - // Iterator to the beginning of `values`. - std::vector slice_shape = reorder_span(node_shape, bound_axis); - std::vector slice_strides = reorder_span(node->strides(), bound_axis); - BufferIterator values_begin(values.data(), ndim, slice_shape.data(), - slice_strides.data()); - std::vector one_more(ndim, 0); - one_more[0] = 1; - auto values_next = values_begin + one_more; - - // Offset used to perterb `values_begin` to the first element of the - // hyperslice along the given bound axis. - std::vector offset(ndim, 0); - - // Third, we iterate over each hyperslice and adjust its values until - // it satisfies the axis-wise bounds. + // We need a way to iterate over each hyperslice along the bound axis and + // adjust it`s values until they satisfy the axis-wise bounds. We do this + // by defining an iterator of `values` that can be used to iterate over the + // values within each hyperslice along the bound axis one after another. We + // can do this by modifying the NumberNode shape and strides such that the + // data for the bound_axis is moved to position 0 (remaining indices are + // shifted back). + std::vector new_shape = reorder_to_move_along_axis(node_shape, bound_axis); + std::vector new_strides = reorder_to_move_along_axis(node->strides(), bound_axis); + // Define an iterator for `values` corresponding to the beginning of slice + // 0 along the bound axis. This iterater will be used to define the start + // of a slice iterater. + BufferIterator slice_0_it(values.data(), ndim, new_shape.data(), + new_strides.data()); + // Determine the size of each slice along the bound axis. + const ssize_t slice_size = std::accumulate(new_shape.begin() + 1, new_shape.end(), 1.0, + std::multiplies()); + + // 3) Iterate over each hyperslice and adjust it's values until they + // satisfy the axis-wise bounds. for (ssize_t slice = 0, stop = node_shape[bound_axis]; slice < stop; ++slice) { - // Determine the amount we need to adjust the initialized values by - // to satisfy the axis-wise bounds for the given hyperslice. + // Determine the amount we need to adjust the initialized values within + // the slice. double delta = compute_bound_axis_slice_delta(slice, bound_axis_sums[slice], bound_axis_info.get_operator(slice), bound_axis_info.get_bound(slice)); - assert(delta >= 0); + if (delta == 0) continue; // Axis-wise bounds are satisfied for slice. + assert(delta >= 0); // Should only increment. - if (delta == 0) continue; // axis-wise bounds are satisfied for slice. + // Determine how much we need to offset slice_0_it to get to the first + // value in the given `slice` + const ssize_t offset = slice * slice_size; - offset[0] = slice; - // Define iterator to the cannonically least index in the given slice - // along the bound axis. - for (auto it = values_begin + offset, it_end = values_next + offset; it != it_end; ++it) { - // Only consider values that fall in the slice. - assert(it.location()[0] == slice); + for (auto slice_it = slice_0_it + offset, slice_end_it = slice_it + slice_size; + slice_it != slice_end_it; ++slice_it) { + assert(slice_it.location()[0] == slice); // We should be in the right slice. - // Determine the index of `it` from `values_begin` - const ssize_t index = static_cast(it - values_begin); - assert(0 <= index && index < values.size()); + // Determine the index of `it` from `slice_0_it` + const ssize_t index = static_cast(slice_it - slice_0_it); + assert(0 <= index && index < static_cast(values.size())); // Determine the amount we can increment the value in the given index. - ssize_t inc = std::min(delta, node->upper_bound(index) - *it); + ssize_t inc = std::min(delta, node->upper_bound(index) - *slice_it); if (inc > 0) { // Apply the increment to both `it` and `delta`. - *it += inc; + *slice_it += inc; delta -= inc; if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. } @@ -296,14 +317,13 @@ void NumberNode::initialize_state(State& state) const { } initialize_state(state, std::move(values)); return; + } else if (bound_axes_info_.size() == 1) { + construct_state_given_exactly_one_bound_axis(this, values); + initialize_state(state, std::move(values)); + return; } - if (bound_axes_info_.size() != 1) { - throw std::invalid_argument("Cannot initialize state with multiple bound axes."); - } - - construct_state_given_exactly_one_bound_axis(this, values); - initialize_state(state, std::move(values)); + throw std::invalid_argument("Cannot initialize state with multiple bound axes."); } void NumberNode::commit(State& state) const noexcept { @@ -327,8 +347,8 @@ void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { assert(upper_bound(i) >= ptr->get(j)); assert(lower_bound(j) <= ptr->get(i)); assert(upper_bound(j) >= ptr->get(i)); - // Assert that i and j are valid indices occurs in ptr->exchange(). - // Exchange occurs IFF (i != j) and (buffer[i] != buffer[j]). + // assert() that i and j are valid indices occurs in ptr->exchange(). + // State change occurs IFF (i != j) and (buffer[i] != buffer[j]). if (ptr->exchange(i, j)) { // If the values at indices i and j were exchanged, update the bound // axis sums. @@ -380,10 +400,9 @@ double NumberNode::upper_bound() const { void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) const { auto ptr = data_ptr(state); value = std::clamp(value, lower_bound(index), upper_bound(index)); - // Assert that i is a valid index occurs in data_ptr->set(). - // Set occurs IFF `value` != buffer[i] . + // assert() that i is a valid index occurs in ptr->set(). + // State change occurs IFF `value` != buffer[index] . if (ptr->set(index, value)) { - // Update the bound axis sums. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } @@ -450,25 +469,21 @@ void check_axis_wise_bounds(const std::vector& bound_ const ssize_t axis = bound_axis_info.axis; if (axis < 0 || axis >= static_cast(shape.size())) { - throw std::invalid_argument( - "Invalid bound axis: " + std::to_string(axis) + - ". Note, negative indexing is not supported for axis-wise bounds."); + throw std::invalid_argument("Invalid bound axis given number array shape."); } // The number of operators defined for the given bound axis const ssize_t num_operators = static_cast(bound_axis_info.operators.size()); if ((num_operators > 1) && (num_operators != shape[axis])) { throw std::invalid_argument( - "Invalid number of axis-wise operators along axis: " + std::to_string(axis) + - " given axis size: " + std::to_string(shape[axis])); + "Invalid number of axis-wise operators given number array shape."); } // The number of operators defined for the given bound axis const ssize_t num_bounds = static_cast(bound_axis_info.bounds.size()); if ((num_bounds > 1) && (num_bounds != shape[axis])) { throw std::invalid_argument( - "Invalid number of axis-wise bounds along axis: " + std::to_string(axis) + - " given axis size: " + std::to_string(shape[axis])); + "Invalid number of axis-wise bounds given number array shape."); } // Checked in BoundAxisInfo constructor @@ -524,10 +539,11 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, for (ssize_t bound_axis = 0, stop = static_cast(bound_axes_info.size()); bound_axis < stop; ++bound_axis) { + assert(0 <= bound_axes_info[bound_axis].axis); assert(bound_axes_info[bound_axis].axis < static_cast(multi_index.size())); // Get the slice along the bound axis the `value_change` occurs in const ssize_t slice = multi_index[bound_axes_info[bound_axis].axis]; - assert(slice < static_cast(bound_axes_sums[bound_axis].size())); + assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); // Offset running sum in slice bound_axes_sums[bound_axis][slice] += value_change; } @@ -636,10 +652,9 @@ void IntegerNode::set_value(State& state, ssize_t index, double value) const { assert(lower_bound(index) <= value); assert(upper_bound(index) >= value); assert(value == std::round(value)); - // Assert that i is a valid index occurs in data_ptr->set(). - // set() occurs IFF `value` != buffer[i]. + // assert() that i is a valid index occurs in ptr->set(). + // State change occurs IFF `value` != buffer[index]. if (ptr->set(index, value)) { - // Update the bound axis. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } @@ -746,8 +761,8 @@ void BinaryNode::flip(State& state, ssize_t i) const { auto ptr = data_ptr(state); // Variable should not be fixed. assert(lower_bound(i) != upper_bound(i)); - // Assert that i is a valid index occurs in ptr->set(). - // set() occurs IFF `value` != buffer[i]. + // assert() that i is a valid index occurs in ptr->set(). + // State change occurs IFF `value` != buffer[i]. if (ptr->set(i, !ptr->get(i))) { // If value changed from 0 -> 1, update the bound axis sums by 1. // If value changed from 1 -> 0, update the bound axis sums by -1. @@ -760,8 +775,8 @@ void BinaryNode::set(State& state, ssize_t i) const { auto ptr = data_ptr(state); // We expect the set to obey the index-wise bounds. assert(upper_bound(i) == 1.0); - // Assert that i is a valid index occurs in ptr->set(). - // set() occurs IFF `value` != buffer[i]. + // assert() that i is a valid index occurs in ptr->set(). + // State change occurs IFF `value` != buffer[i]. if (ptr->set(i, 1.0)) { // If value changed from 0 -> 1, update the bound axis sums by 1. update_bound_axis_slice_sums(state, i, 1.0); @@ -773,8 +788,8 @@ void BinaryNode::unset(State& state, ssize_t i) const { auto ptr = data_ptr(state); // We expect the set to obey the index-wise bounds. assert(lower_bound(i) == 0.0); - // Assert that i is a valid index occurs in ptr->set(). - // set occurs IFF `value` != buffer[i]. + // assert() that i is a valid index occurs in ptr->set(). + // State change occurs IFF `value` != buffer[i]. if (ptr->set(i, 0.0)) { // If value changed from 1 -> 0, update the bound axis sums by -1. update_bound_axis_slice_sums(state, i, -1.0); diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 85c7e157..778d8cdf 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -28,56 +28,50 @@ namespace dwave::optimization { TEST_CASE("BoundAxisInfo") { GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { - REQUIRE_THROWS_WITH( - NumberNode::BoundAxisInfo(0, std::vector{}, - std::vector{1.0}), - "Bad axis-wise bounds for axis: 0, `operators` and `bounds` must each have " - "non-zero size."); + std::vector operators; + std::vector bounds{1.0}; + REQUIRE_THROWS_WITH(NumberNode::BoundAxisInfo(0, operators, bounds), + "Axis-wise `operators` and `bounds` must have non-zero size."); } GIVEN("BoundAxisInfo(axis = 0, operators = {<=}, bounds = {})") { - REQUIRE_THROWS_WITH( - NumberNode::BoundAxisInfo(0, - std::vector{ - NumberNode::NumberNode::LessEqual}, - std::vector{}), - "Bad axis-wise bounds for axis: 0, `operators` and `bounds` must each have " - "non-zero size."); + std::vector operators{NumberNode::LessEqual}; + std::vector bounds; + REQUIRE_THROWS_WITH(NumberNode::BoundAxisInfo(0, operators, bounds), + "Axis-wise `operators` and `bounds` must have non-zero size."); } GIVEN("BoundAxisInfo(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { + std::vector operators{NumberNode::LessEqual, + NumberNode::Equal, NumberNode::Equal}; + std::vector bounds{2.0, 1.0}; REQUIRE_THROWS_WITH( - NumberNode::BoundAxisInfo( - 1, - std::vector{ - NumberNode::LessEqual, NumberNode::Equal, NumberNode::Equal}, - std::vector{2.0, 1.0}), - "Bad axis-wise bounds for axis: 1, `operators` and `bounds` should have same size " - "if neither has size 1."); + NumberNode::BoundAxisInfo(1, operators, bounds), + "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); } GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0})") { - NumberNode::BoundAxisInfo bound_axis( - 2, std::vector{NumberNode::Equal}, - std::vector{1.0}); + std::vector operators{NumberNode::Equal}; + std::vector bounds{1.0}; + NumberNode::BoundAxisInfo bound_axis(2, operators, bounds); + THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); - CHECK_THAT(bound_axis.operators, RangeEquals({NumberNode::Equal})); - CHECK_THAT(bound_axis.bounds, RangeEquals({1.0})); + CHECK_THAT(bound_axis.operators, RangeEquals(operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bounds)); } } GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { - NumberNode::BoundAxisInfo bound_axis( - 2, - std::vector{NumberNode::Equal, NumberNode::LessEqual, - NumberNode::GreaterEqual}, - std::vector{1.0, 2.0, 3.0}); + std::vector operators{ + NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector bounds{1.0, 2.0, 3.0}; + NumberNode::BoundAxisInfo bound_axis(2, operators, bounds); + THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); - CHECK_THAT(bound_axis.operators, RangeEquals({NumberNode::Equal, NumberNode::LessEqual, - NumberNode::GreaterEqual})); - CHECK_THAT(bound_axis.bounds, RangeEquals({1.0, 2.0, 3.0})); + CHECK_THAT(bound_axis.operators, RangeEquals(operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bounds)); } } } @@ -498,127 +492,113 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { - NumberNode::BoundAxisInfo bound_axis{ - -1, std::vector{NumberNode::Equal}, - std::vector{1.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid bound axis: -1. Note, negative indexing is not supported for " - "axis-wise bounds."); + std::vector operators{NumberNode::Equal}; + std::vector bounds{1.0}; + std::vector bound_axes{{-1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid bound axis given number array shape."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { - NumberNode::BoundAxisInfo bound_axis{ - 2, std::vector{NumberNode::Equal}, - std::vector{1.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid bound axis: 2. Note, negative indexing is not supported for " - "axis-wise bounds."); + std::vector operators{NumberNode::Equal}; + std::vector bounds{1.0}; + std::vector bound_axes{{2, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid bound axis given number array shape."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many operators.") { - NumberNode::BoundAxisInfo bound_axis{ - 1, - std::vector{NumberNode::LessEqual, NumberNode::Equal, - NumberNode::Equal, NumberNode::Equal}, - std::vector{1.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); + std::vector operators{ + NumberNode::LessEqual, NumberNode::Equal, NumberNode::Equal, NumberNode::Equal}; + std::vector bounds{1.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise operators given number array shape."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { - NumberNode::BoundAxisInfo bound_axis{1, - std::vector{ - NumberNode::LessEqual, NumberNode::Equal}, - std::vector{1.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); + std::vector operators{NumberNode::LessEqual, + NumberNode::Equal}; + std::vector bounds{1.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise operators given number array shape."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many bounds.") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::LessEqual}, - std::vector{1.0, 2.0, 3.0, 4.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + std::vector operators{NumberNode::Equal}; + std::vector bounds{1.0, 2.0, 3.0, 4.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise bounds given number array shape."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::LessEqual}, - std::vector{1.0, 2.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + std::vector operators{NumberNode::LessEqual}; + std::vector bounds{1.0, 2.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise bounds given number array shape."); } GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::Equal}, - std::vector{1.0}}; + std::vector operators{NumberNode::Equal}; + std::vector bounds{1.0}; + NumberNode::BoundAxisInfo bound_axis{1, operators, bounds}; + REQUIRE_THROWS_WITH( - graph.emplace_node( + graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, std::vector{bound_axis, bound_axis}), "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { - NumberNode::BoundAxisInfo bound_axis_0{ - 0, std::vector{NumberNode::LessEqual}, - std::vector{1.0}}; - NumberNode::BoundAxisInfo bound_axis_1{ - 1, std::vector{NumberNode::LessEqual}, - std::vector{1.0}}; + std::vector operators{NumberNode::LessEqual}; + std::vector bounds{1.0}; + NumberNode::BoundAxisInfo bound_axis_0{0, operators, bounds}; + NumberNode::BoundAxisInfo bound_axis_1{1, operators, bounds}; + REQUIRE_THROWS_WITH( - graph.emplace_node( + graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, std::vector{bound_axis_0, bound_axis_1}), "Axis-wise bounds are supported for at most one axis."); } - GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::Equal}, - std::vector{0.1}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Axis wise bounds for integral number arrays must be intregral."); + GIVEN("(2x3x4)-BinaryNode with non-integral axis-wise bounds") { + std::vector operators{NumberNode::Equal}; + std::vector bounds{0.1}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Axis wise bounds for integral number arrays must be intregral."); } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 0, - std::vector{NumberNode::Equal, NumberNode::LessEqual, - NumberNode::GreaterEqual}, - std::vector{5.0, 2.0, 3.0}}; - + std::vector operators{ + NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector bounds{5.0, 2.0, 3.0}; + std::vector bound_axes{{0, operators, bounds}}; // Each hyperslice along axis 0 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 0) that results in a // sum equal to 5. - graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, + std::nullopt, bound_axes); WHEN("We create a state by initialize_state()") { REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); @@ -627,15 +607,12 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{1, - std::vector{ - NumberNode::Equal, NumberNode::GreaterEqual}, - std::vector{5.0, 7.0}}; - - graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector operators{NumberNode::Equal, + NumberNode::GreaterEqual}; + std::vector bounds{5.0, 7.0}; + std::vector bound_axes{{1, operators, bounds}}; + graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, + std::nullopt, bound_axes); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 1 has size 6. There is no feasible @@ -647,15 +624,12 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{2, - std::vector{ - NumberNode::Equal, NumberNode::LessEqual}, - std::vector{5.0, -1.0}}; - - graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector operators{NumberNode::Equal, + NumberNode::LessEqual}; + std::vector bounds{5.0, -1.0}; + std::vector bound_axes{{2, operators, bounds}}; + graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, + std::nullopt, bound_axes); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 2 has size 6. There is no feasible @@ -667,23 +641,19 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 0, - std::vector{NumberNode::Equal, NumberNode::LessEqual, - NumberNode::GreaterEqual}, - std::vector{1.0, 2.0, 3.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector operators{ + NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector bounds{1.0, 2.0, 3.0}; + std::vector bound_axes{{0, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We create a state by initialize_state()") { @@ -716,23 +686,19 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 1, - std::vector{NumberNode::LessEqual, - NumberNode::GreaterEqual}, - std::vector{1.0, 5.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector operators{NumberNode::LessEqual, + NumberNode::GreaterEqual}; + std::vector bounds{1.0, 5.0}; + std::vector bound_axes{{1, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We create a state by initialize_state()") { @@ -764,22 +730,19 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{2, - std::vector{ - NumberNode::Equal, NumberNode::GreaterEqual}, - std::vector{3.0, 6.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector operators{NumberNode::Equal, + NumberNode::GreaterEqual}; + std::vector bounds{3.0, 6.0}; + std::vector bound_axes{{2, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We create a state by initialize_state()") { @@ -811,23 +774,19 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 0, - std::vector{NumberNode::Equal, NumberNode::LessEqual, - NumberNode::GreaterEqual}, - std::vector{1.0, 2.0, 3.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, - std::vector{bound_axis}); + std::vector operators{ + NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector bounds{1.0, 2.0, 3.0}; + std::vector bound_axes{{0, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We initialize three invalid states") { @@ -1088,7 +1047,8 @@ TEST_CASE("IntegerNode") { } } - GIVEN("Double precision numbers, which may fall outside integer range or are not integral") { + GIVEN("Double precision numbers, which may fall outside integer range or are not " + "integral") { IntegerNode inode({1}); THEN("The state is not deterministic") { CHECK(!inode.deterministic_state()); } @@ -1367,123 +1327,109 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { - NumberNode::BoundAxisInfo bound_axis{ - -2, std::vector{NumberNode::Equal}, - std::vector{20.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid bound axis: -2. Note, negative indexing is not supported for " - "axis-wise bounds."); + std::vector operators{NumberNode::Equal}; + std::vector bounds{20.0}; + std::vector bound_axes{{-2, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Invalid bound axis given number array shape."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { - NumberNode::BoundAxisInfo bound_axis{ - 3, std::vector{NumberNode::Equal}, - std::vector{10.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid bound axis: 3. Note, negative indexing is not supported for " - "axis-wise bounds."); + std::vector operators{NumberNode::Equal}; + std::vector bounds{10.0}; + std::vector bound_axes{{3, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Invalid bound axis given number array shape."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many operators.") { - NumberNode::BoundAxisInfo bound_axis{ - 1, - std::vector{NumberNode::LessEqual, NumberNode::Equal, - NumberNode::Equal, NumberNode::Equal}, - std::vector{-10.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); + std::vector operators{ + NumberNode::LessEqual, NumberNode::Equal, NumberNode::Equal, NumberNode::Equal}; + std::vector bounds{-10.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise operators given number array shape."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { - NumberNode::BoundAxisInfo bound_axis{1, - std::vector{ - NumberNode::LessEqual, NumberNode::Equal}, - std::vector{-11.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise operators along axis: 1 given axis size: 3"); + std::vector operators{NumberNode::LessEqual, + NumberNode::Equal}; + std::vector bounds{-11.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise operators given number array shape."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many bounds.") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::LessEqual}, - std::vector{-10.0, 20.0, 30.0, 40.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + std::vector operators{NumberNode::LessEqual}; + std::vector bounds{-10.0, 20.0, 30.0, 40.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise bounds given number array shape."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::LessEqual}, - std::vector{111.0, -223.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Invalid number of axis-wise bounds along axis: 1 given axis size: 3"); + std::vector operators{NumberNode::LessEqual}; + std::vector bounds{111.0, -223.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Invalid number of axis-wise bounds given number array shape."); } GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { - NumberNode::BoundAxisInfo bound_axis{ - 1, std::vector{NumberNode::Equal}, - std::vector{100.0}}; + std::vector operators{NumberNode::Equal}; + std::vector bounds{100.0}; + NumberNode::BoundAxisInfo bound_axis{1, operators, bounds}; + REQUIRE_THROWS_WITH( - graph.emplace_node( + graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, std::vector{bound_axis, bound_axis}), "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { - NumberNode::BoundAxisInfo bound_axis_0{ - 0, std::vector{NumberNode::LessEqual}, - std::vector{11.0}}; - NumberNode::BoundAxisInfo bound_axis_1{ - 1, std::vector{NumberNode::LessEqual}, - std::vector{12.0}}; + std::vector operators{NumberNode::Equal}; + std::vector bounds{100.0}; + NumberNode::BoundAxisInfo bound_axis_0{0, operators, bounds}; + NumberNode::BoundAxisInfo bound_axis_1{1, operators, bounds}; + REQUIRE_THROWS_WITH( - graph.emplace_node( + graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, std::vector{bound_axis_0, bound_axis_1}), "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - NumberNode::BoundAxisInfo bound_axis{ - 2, std::vector{NumberNode::LessEqual}, - std::vector{11.0, 12.0001, 0.0, 0.0}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis}), - "Axis wise bounds for integral number arrays must be intregral."); + std::vector operators{NumberNode::LessEqual}; + std::vector bounds{11.0, 12.0001, 0.0}; + std::vector bound_axes{{1, operators, bounds}}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Axis wise bounds for integral number arrays must be intregral."); } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{0, - std::vector{ - NumberNode::Equal, NumberNode::LessEqual}, - std::vector{5.0, -31.0}}; - - graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{NumberNode::Equal, + NumberNode::LessEqual}; + std::vector bounds{5.0, -31.0}; + std::vector bound_axes{{0, operators, bounds}}; + graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 0 has size 6. There is no feasible @@ -1495,16 +1441,11 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 1, - std::vector{NumberNode::GreaterEqual, - NumberNode::Equal, NumberNode::Equal}, - std::vector{33.0, 0.0, 0.0}}; - - graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{NumberNode::GreaterEqual, + NumberNode::Equal, NumberNode::Equal}; + std::vector bounds{33.0, 0.0, 0.0}; + std::vector bound_axes{{1, operators, bounds}}; + graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 1 has size 4. There is no feasible @@ -1516,15 +1457,11 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{2, - std::vector{ - NumberNode::GreaterEqual, NumberNode::Equal}, - std::vector{-1.0, 49.0}}; - - graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{NumberNode::GreaterEqual, + NumberNode::Equal}; + std::vector bounds{-1.0, 49.0}; + std::vector bound_axes{{2, operators, bounds}}; + graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); WHEN("We create a state by initialize_state()") { // Each hyperslice along axis 2 has size 6. There is no feasible @@ -1536,22 +1473,19 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{0, - std::vector{ - NumberNode::Equal, NumberNode::GreaterEqual}, - std::vector{-21.0, 9.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{NumberNode::Equal, + NumberNode::GreaterEqual}; + std::vector bounds{-21.0, 9.0}; + std::vector bound_axes{{0, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We create a state by initialize_state()") { @@ -1584,23 +1518,19 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 1, - std::vector{ - NumberNode::Equal, NumberNode::GreaterEqual, NumberNode::LessEqual}, - std::vector{0.0, -2.0, 0.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{ + NumberNode::Equal, NumberNode::GreaterEqual, NumberNode::LessEqual}; + std::vector bounds{0.0, -2.0, 0.0}; + std::vector bound_axes{{1, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We create a state by initialize_state()") { @@ -1636,22 +1566,19 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{2, - std::vector{ - NumberNode::Equal, NumberNode::GreaterEqual}, - std::vector{23.0, 14.0}}; - - auto bnode_ptr = graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{NumberNode::Equal, + NumberNode::GreaterEqual}; + std::vector bounds{23.0, 14.0}; + std::vector bound_axes{{2, operators, bounds}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bound_axis.axis == bnode_bound_axis.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(bnode_bound_axis.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(bnode_bound_axis.bounds)); + CHECK(bound_axes[0].axis == bnode_bound_axis.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } WHEN("We create a state by initialize_state()") { @@ -1684,24 +1611,20 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { auto graph = Graph(); - - NumberNode::BoundAxisInfo bound_axis{ - 1, - std::vector{NumberNode::Equal, NumberNode::LessEqual, - NumberNode::GreaterEqual}, - std::vector{11.0, 2.0, 5.0}}; - - auto inode_ptr = graph.emplace_node( - std::initializer_list{2, 3, 2}, -5, 8, - std::vector{bound_axis}); + std::vector operators{ + NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector bounds{11.0, 2.0, 5.0}; + std::vector bound_axes{{1, operators, bounds}}; + auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(inode_ptr->axis_wise_bounds().size() == 1); const NumberNode::BoundAxisInfo inode_bound_axis_ptr = inode_ptr->axis_wise_bounds().data()[0]; - CHECK(bound_axis.axis == inode_bound_axis_ptr.axis); - CHECK_THAT(bound_axis.operators, RangeEquals(inode_bound_axis_ptr.operators)); - CHECK_THAT(bound_axis.bounds, RangeEquals(inode_bound_axis_ptr.bounds)); + CHECK(bound_axes[0].axis == inode_bound_axis_ptr.axis); + CHECK_THAT(bound_axes[0].operators, RangeEquals(inode_bound_axis_ptr.operators)); + CHECK_THAT(bound_axes[0].bounds, RangeEquals(inode_bound_axis_ptr.bounds)); } WHEN("We initialize three invalid states") { From 146f32ecbde5e095c955ded077c7dd4597f05fd2 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 2 Feb 2026 09:49:38 -0800 Subject: [PATCH 08/22] Fixed issue in `NumberNode::initialize()` When constructing a state that satisfies exactly one axis-wise bound. --- dwave/optimization/src/nodes/numbers.cpp | 83 ++++++++++++++---------- tests/cpp/nodes/test_numbers.cpp | 35 ++++++---- 2 files changed, 69 insertions(+), 49 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index bb32c389..9cfdd578 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -187,10 +187,9 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat std::move(bound_axes_sums)); } -/// Given a `span` (typically containing strides or shape), we reorder the -/// values of the span such that the given `axis` is moved to the 0th index. -std::vector reorder_to_move_along_axis(const std::span span, - const ssize_t axis) { +/// Given a `span` (typically containing strides or shape), reorder the values +/// of the span such that the given `axis` is moved to the 0th index. +std::vector shift_axis_data(const std::span span, const ssize_t axis) { const ssize_t ndim = span.size(); std::vector output; output.reserve(ndim); @@ -202,6 +201,22 @@ std::vector reorder_to_move_along_axis(const std::span s return output; } +/// Undo the operation defined by `shift_axis_data()`. +std::vector undo_shift_axis_data(const std::span span, const ssize_t axis) { + const ssize_t ndim = span.size(); + std::vector output; + output.reserve(ndim); + + ssize_t i_span = 1; + for (ssize_t i = 0; i < ndim; ++i) { + if (i == axis) + output.emplace_back(span[0]); + else + output.emplace_back(span[i_span++]); + } + return output; +} + /// Given a `slice` along a bound axis in a NumberNode where the sum of it's /// values are given by `sum`, determine the non-negative amount `delta` /// needed to be added to `sum` to satisfy the expression: (sum+delta) op bound @@ -222,9 +237,8 @@ double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, return 0.0; case NumberNode::GreaterEqual: // If sum is less than bound, return the amount needed to equal it. - if (sum < bound) return bound - sum; // Otherwise, sum satisfies bound. - return 0.0; + return (sum < bound) ? (bound - sum) : 0.0; default: unreachable(); } @@ -232,8 +246,8 @@ double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, /// Given a NumberNod and exactly one axis-wise bound defined for NumberNode, /// assign values to `values` (in-place) to satisfy the axis-wise bound. This method -/// 1) Initially sets `values[i] = lower_bound(i)` for all i. -/// 2) Incremements the values within each hyperslice until they satisfy +/// A) Initially sets `values[i] = lower_bound(i)` for all i. +/// B) Incremements the values within each hyperslice until they satisfy /// the axis-wise bound (should this be possible). void construct_state_given_exactly_one_bound_axis(const NumberNode* node, std::vector& values) { @@ -247,28 +261,24 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, // 2) Determine the hyperslice sums for the bound axis. This could be // done during the previous loop if we want to improve performance. assert(node->axis_wise_bounds().size() == 1); - const std::vector bound_axis_sums = get_bound_axes_sums(node, values)[0]; - // Obtain the axis-wise bound - const NumberNode::BoundAxisInfo& bound_axis_info = node->axis_wise_bounds()[0]; + const std::vector bound_axis_sums = get_bound_axes_sums(node, values).front(); + const NumberNode::BoundAxisInfo& bound_axis_info = node->axis_wise_bounds().front(); const ssize_t bound_axis = bound_axis_info.axis; assert(0 <= bound_axis && bound_axis < ndim); // We need a way to iterate over each hyperslice along the bound axis and // adjust it`s values until they satisfy the axis-wise bounds. We do this - // by defining an iterator of `values` that can be used to iterate over the - // values within each hyperslice along the bound axis one after another. We - // can do this by modifying the NumberNode shape and strides such that the - // data for the bound_axis is moved to position 0 (remaining indices are - // shifted back). - std::vector new_shape = reorder_to_move_along_axis(node_shape, bound_axis); - std::vector new_strides = reorder_to_move_along_axis(node->strides(), bound_axis); - // Define an iterator for `values` corresponding to the beginning of slice - // 0 along the bound axis. This iterater will be used to define the start - // of a slice iterater. - BufferIterator slice_0_it(values.data(), ndim, new_shape.data(), - new_strides.data()); - // Determine the size of each slice along the bound axis. - const ssize_t slice_size = std::accumulate(new_shape.begin() + 1, new_shape.end(), 1.0, + // by defining an iterator of `values` that traverses each hyperslice one + // after another. This is equivalent to adjusting NumberNode shape and + // strides such that the data for the bound_axis is moved to position 0. + const std::vector buff_shape = shift_axis_data(node_shape, bound_axis); + const std::vector buff_strides = shift_axis_data(node->strides(), bound_axis); + // Define an iterator for `values` corresponding with the beginning of + // slice 0 along the bound axis. + BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), + buff_strides.data()); + // Determine the size of each hyperslice along the bound axis. + const ssize_t slice_size = std::accumulate(buff_shape.begin() + 1, buff_shape.end(), 1.0, std::multiplies()); // 3) Iterate over each hyperslice and adjust it's values until they @@ -283,21 +293,24 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, assert(delta >= 0); // Should only increment. // Determine how much we need to offset slice_0_it to get to the first - // value in the given `slice` + // index in the given `slice` const ssize_t offset = slice * slice_size; - - for (auto slice_it = slice_0_it + offset, slice_end_it = slice_it + slice_size; - slice_it != slice_end_it; ++slice_it) { - assert(slice_it.location()[0] == slice); // We should be in the right slice. - - // Determine the index of `it` from `slice_0_it` - const ssize_t index = static_cast(slice_it - slice_0_it); + // Iterate over all indices in the given slice. + for (auto slice_begin_it = slice_0_it + offset, slice_end_it = slice_begin_it + slice_size; + slice_begin_it != slice_end_it; ++slice_begin_it) { + assert(slice_begin_it.location()[0] == slice); // We should be in the right slice. + // Determine the "true" index of `slice_it` given the node shape + ssize_t index = ravel_multi_index( + undo_shift_axis_data(slice_begin_it.location(), bound_axis), node_shape); assert(0 <= index && index < static_cast(values.size())); + // Sanity check that we can correctly reverse the conversion. + assert(std::ranges::equal(shift_axis_data(unravel_index(index, node_shape), bound_axis), + slice_begin_it.location())); // Determine the amount we can increment the value in the given index. - ssize_t inc = std::min(delta, node->upper_bound(index) - *slice_it); + const double inc = std::min(delta, node->upper_bound(index) - *slice_begin_it); if (inc > 0) { // Apply the increment to both `it` and `delta`. - *slice_it += inc; + *slice_begin_it += inc; delta -= inc; if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. } diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 778d8cdf..752c1d73 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -641,12 +641,14 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); + std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0}; + std::vector upper_bounds{0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1}; std::vector operators{ NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; std::vector bounds{1.0, 2.0, 3.0}; std::vector bound_axes{{0, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, bound_axes); + lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); @@ -667,11 +669,12 @@ TEST_CASE("BinaryNode") { // ... [4 5 6 7] // print(a[2, :, :].flatten()) // ... [ 8 9 10 11] - std::vector expected_init{1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0}; - // Cannonically least state that satisfies bounds + std::vector expected_init{0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1}; + // Cannonically least state that satisfies the index- and axis-wise + // bounds // slice 0 slice 1 slice 2 - // 1, 0 0, 0 1, 1 - // 0, 0 0, 0 1, 0 + // 0, 0 0, 0 1, 1 + // 1, 0 0, 0 0, 1 auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); @@ -686,12 +689,14 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); + std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; + std::vector upper_bounds{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; std::vector operators{NumberNode::LessEqual, NumberNode::GreaterEqual}; std::vector bounds{1.0, 5.0}; std::vector bound_axes{{1, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, bound_axes); + lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); @@ -710,13 +715,12 @@ TEST_CASE("BinaryNode") { // ... [0 1 4 5 8 9] // print(a[:, 1, :].flatten()) // ... [ 2 3 6 7 10 11] - std::vector expected_init{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0}; + std::vector expected_init{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; // Cannonically least state that satisfies bounds // slice 0 slice 1 // 0, 0 1, 1 // 0, 0 1, 1 - // 0, 0 1, 0 - + // 0, 0 0, 1 auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); THEN("The bound axis sums and state are correct") { @@ -730,12 +734,14 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); + std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}; + std::vector upper_bounds{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; std::vector operators{NumberNode::Equal, NumberNode::GreaterEqual}; std::vector bounds{3.0, 6.0}; std::vector bound_axes{{2, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, bound_axes); + lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); @@ -754,12 +760,13 @@ TEST_CASE("BinaryNode") { // ... [ 0 2 4 6 8 10] // print(a[:, :, 1].flatten()) // ... [ 1 3 5 7 9 11] - std::vector expected_init{1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0, 1}; - // Cannonically least state that satisfies bounds + std::vector expected_init{0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1}; + // Cannonically least state that satisfies the index- and axis-wise + // bounds // slice 0 slice 1 - // 1, 1 1, 1 + // 0, 1 1, 1 // 1, 0 1, 1 - // 0, 0 1, 1 + // 0, 1 1, 1 auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); From 941a120ac5f2186b3d1e72f253cda93c2e8b2d85 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 2 Feb 2026 13:02:14 -0800 Subject: [PATCH 09/22] BoundAxisOperator is now an enum class Needed for Cython interface. Changed relevant C++ code. Used aliases in C++ NumberNode tests. --- .../dwave-optimization/nodes/numbers.hpp | 10 +- dwave/optimization/src/nodes/numbers.cpp | 12 +- tests/cpp/nodes/test_numbers.cpp | 211 ++++++++---------- 3 files changed, 109 insertions(+), 124 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index d503298e..fa046987 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -29,7 +29,7 @@ namespace dwave::optimization { class NumberNode : public ArrayOutputMixin, public DecisionNode { public: /// Allowable axis-wise bound operators. - enum BoundAxisOperator { Equal, LessEqual, GreaterEqual }; + enum class BoundAxisOperator { Equal, LessEqual, GreaterEqual }; /// Struct for stateless axis-wise bound information. Given an `axis`, define /// constraints on the sum of the values in each slice along `axis`. @@ -41,13 +41,13 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { BoundAxisInfo(ssize_t axis, std::vector axis_operators, std::vector axis_bounds); /// The bound axis - const ssize_t axis; + ssize_t axis; /// Operator for ALL axis slices (vector has length one) or operator*s* PER /// slice (length of vector is equal to the number of slices). - const std::vector operators; + std::vector operators; /// Bound for ALL axis slices (vector has length one) or bound*s* PER slice /// (length of vector is equal to the number of slices). - const std::vector bounds; + std::vector bounds; /// Obtain the bound associated with a given slice along `axis`. double get_bound(const ssize_t slice) const; @@ -171,7 +171,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::vector upper_bounds_; /// Stateless information on each bound axis. - const std::vector bound_axes_info_; + std::vector bound_axes_info_; }; /// A contiguous block of integer numbers. diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 9cfdd578..a63bbd12 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -142,13 +142,13 @@ bool satisfies_axis_wise_bounds(const std::vector& bo for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); slice < stop_slice; ++slice) { switch (bound_axis_info.get_operator(slice)) { - case NumberNode::Equal: + case NumberNode::BoundAxisOperator::Equal: if (bound_axis_sums[slice] != bound_axis_info.get_bound(slice)) return false; break; - case NumberNode::LessEqual: + case NumberNode::BoundAxisOperator::LessEqual: if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) return false; break; - case NumberNode::GreaterEqual: + case NumberNode::BoundAxisOperator::GreaterEqual: if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; break; default: @@ -227,15 +227,15 @@ std::vector undo_shift_axis_data(const std::span span, c double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, const NumberNode::BoundAxisOperator op, const double bound) { switch (op) { - case NumberNode::Equal: + case NumberNode::BoundAxisOperator::Equal: if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); // If error was not thrown, return amount needed to satisfy bound. return bound - sum; - case NumberNode::LessEqual: + case NumberNode::BoundAxisOperator::LessEqual: if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); // If error was not thrown, sum satisfies bound. return 0.0; - case NumberNode::GreaterEqual: + case NumberNode::BoundAxisOperator::GreaterEqual: // If sum is less than bound, return the amount needed to equal it. // Otherwise, sum satisfies bound. return (sum < bound) ? (bound - sum) : 0.0; diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 752c1d73..a912542a 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -26,34 +26,39 @@ using Catch::Matchers::RangeEquals; namespace dwave::optimization { +using BoundAxisInfo = NumberNode::BoundAxisInfo; +using BoundAxisOperator = NumberNode::BoundAxisOperator; +using NumberNode::BoundAxisOperator::Equal; +using NumberNode::BoundAxisOperator::GreaterEqual; +using NumberNode::BoundAxisOperator::LessEqual; + TEST_CASE("BoundAxisInfo") { GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { - std::vector operators; + std::vector operators; std::vector bounds{1.0}; - REQUIRE_THROWS_WITH(NumberNode::BoundAxisInfo(0, operators, bounds), + REQUIRE_THROWS_WITH(BoundAxisInfo(0, operators, bounds), "Axis-wise `operators` and `bounds` must have non-zero size."); } GIVEN("BoundAxisInfo(axis = 0, operators = {<=}, bounds = {})") { - std::vector operators{NumberNode::LessEqual}; + std::vector operators{LessEqual}; std::vector bounds; - REQUIRE_THROWS_WITH(NumberNode::BoundAxisInfo(0, operators, bounds), + REQUIRE_THROWS_WITH(BoundAxisInfo(0, operators, bounds), "Axis-wise `operators` and `bounds` must have non-zero size."); } GIVEN("BoundAxisInfo(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { - std::vector operators{NumberNode::LessEqual, - NumberNode::Equal, NumberNode::Equal}; + std::vector operators{LessEqual, Equal, Equal}; std::vector bounds{2.0, 1.0}; REQUIRE_THROWS_WITH( - NumberNode::BoundAxisInfo(1, operators, bounds), + BoundAxisInfo(1, operators, bounds), "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); } GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0})") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{1.0}; - NumberNode::BoundAxisInfo bound_axis(2, operators, bounds); + BoundAxisInfo bound_axis(2, operators, bounds); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); @@ -63,10 +68,9 @@ TEST_CASE("BoundAxisInfo") { } GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { - std::vector operators{ - NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{1.0, 2.0, 3.0}; - NumberNode::BoundAxisInfo bound_axis(2, operators, bounds); + BoundAxisInfo bound_axis(2, operators, bounds); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); @@ -492,9 +496,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{1.0}; - std::vector bound_axes{{-1, operators, bounds}}; + std::vector bound_axes{{-1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -502,9 +506,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{1.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -512,10 +516,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many operators.") { - std::vector operators{ - NumberNode::LessEqual, NumberNode::Equal, NumberNode::Equal, NumberNode::Equal}; + std::vector operators{LessEqual, Equal, Equal, Equal}; std::vector bounds{1.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -523,10 +526,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector operators{NumberNode::LessEqual, - NumberNode::Equal}; + std::vector operators{LessEqual, Equal}; std::vector bounds{1.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -534,9 +536,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many bounds.") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{1.0, 2.0, 3.0, 4.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -544,9 +546,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector operators{NumberNode::LessEqual}; + std::vector operators{LessEqual}; std::vector bounds{1.0, 2.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -554,34 +556,34 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{1.0}; - NumberNode::BoundAxisInfo bound_axis{1, operators, bounds}; + BoundAxisInfo bound_axis{1, operators, bounds}; REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis, bound_axis}), + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, + std::vector{bound_axis, bound_axis}), "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { - std::vector operators{NumberNode::LessEqual}; + std::vector operators{LessEqual}; std::vector bounds{1.0}; - NumberNode::BoundAxisInfo bound_axis_0{0, operators, bounds}; - NumberNode::BoundAxisInfo bound_axis_1{1, operators, bounds}; + BoundAxisInfo bound_axis_0{0, operators, bounds}; + BoundAxisInfo bound_axis_1{1, operators, bounds}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis_0, bound_axis_1}), + std::vector{bound_axis_0, bound_axis_1}), "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-BinaryNode with non-integral axis-wise bounds") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{0.1}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -590,10 +592,9 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{ - NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{5.0, 2.0, 3.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, operators, bounds}}; // Each hyperslice along axis 0 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 0) that results in a // sum equal to 5. @@ -607,10 +608,9 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{NumberNode::Equal, - NumberNode::GreaterEqual}; + std::vector operators{Equal, GreaterEqual}; std::vector bounds{5.0, 7.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, bound_axes); @@ -624,10 +624,9 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector operators{NumberNode::Equal, - NumberNode::LessEqual}; + std::vector operators{Equal, LessEqual}; std::vector bounds{5.0, -1.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, operators, bounds}}; graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, bound_axes); @@ -643,16 +642,15 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0}; std::vector upper_bounds{0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1}; - std::vector operators{ - NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{1.0, 2.0, 3.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -691,16 +689,15 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; std::vector upper_bounds{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; - std::vector operators{NumberNode::LessEqual, - NumberNode::GreaterEqual}; + std::vector operators{LessEqual, GreaterEqual}; std::vector bounds{1.0, 5.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -736,16 +733,15 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}; std::vector upper_bounds{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; - std::vector operators{NumberNode::Equal, - NumberNode::GreaterEqual}; + std::vector operators{Equal, GreaterEqual}; std::vector bounds{3.0, 6.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -781,16 +777,15 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{ - NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{1.0, 2.0, 3.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1334,9 +1329,9 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{20.0}; - std::vector bound_axes{{-2, operators, bounds}}; + std::vector bound_axes{{-2, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -1344,9 +1339,9 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{10.0}; - std::vector bound_axes{{3, operators, bounds}}; + std::vector bound_axes{{3, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1354,10 +1349,9 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many operators.") { - std::vector operators{ - NumberNode::LessEqual, NumberNode::Equal, NumberNode::Equal, NumberNode::Equal}; + std::vector operators{LessEqual, Equal, Equal, Equal}; std::vector bounds{-10.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1365,10 +1359,9 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector operators{NumberNode::LessEqual, - NumberNode::Equal}; + std::vector operators{LessEqual, Equal}; std::vector bounds{-11.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1376,9 +1369,9 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many bounds.") { - std::vector operators{NumberNode::LessEqual}; + std::vector operators{LessEqual}; std::vector bounds{-10.0, 20.0, 30.0, 40.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1386,9 +1379,9 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector operators{NumberNode::LessEqual}; + std::vector operators{LessEqual}; std::vector bounds{111.0, -223.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1396,34 +1389,34 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{100.0}; - NumberNode::BoundAxisInfo bound_axis{1, operators, bounds}; + BoundAxisInfo bound_axis{1, operators, bounds}; REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis, bound_axis}), + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, + std::vector{bound_axis, bound_axis}), "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { - std::vector operators{NumberNode::Equal}; + std::vector operators{Equal}; std::vector bounds{100.0}; - NumberNode::BoundAxisInfo bound_axis_0{0, operators, bounds}; - NumberNode::BoundAxisInfo bound_axis_1{1, operators, bounds}; + BoundAxisInfo bound_axis_0{0, operators, bounds}; + BoundAxisInfo bound_axis_1{1, operators, bounds}; REQUIRE_THROWS_WITH( graph.emplace_node( std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis_0, bound_axis_1}), + std::vector{bound_axis_0, bound_axis_1}), "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - std::vector operators{NumberNode::LessEqual}; + std::vector operators{LessEqual}; std::vector bounds{11.0, 12.0001, 0.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1432,10 +1425,9 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{NumberNode::Equal, - NumberNode::LessEqual}; + std::vector operators{Equal, LessEqual}; std::vector bounds{5.0, -31.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, operators, bounds}}; graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); WHEN("We create a state by initialize_state()") { @@ -1448,10 +1440,9 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{NumberNode::GreaterEqual, - NumberNode::Equal, NumberNode::Equal}; + std::vector operators{GreaterEqual, Equal, Equal}; std::vector bounds{33.0, 0.0, 0.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); WHEN("We create a state by initialize_state()") { @@ -1464,10 +1455,9 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector operators{NumberNode::GreaterEqual, - NumberNode::Equal}; + std::vector operators{GreaterEqual, Equal}; std::vector bounds{-1.0, 49.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, operators, bounds}}; graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); WHEN("We create a state by initialize_state()") { @@ -1480,16 +1470,15 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{NumberNode::Equal, - NumberNode::GreaterEqual}; + std::vector operators{Equal, GreaterEqual}; std::vector bounds{-21.0, 9.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1525,16 +1514,15 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{ - NumberNode::Equal, NumberNode::GreaterEqual, NumberNode::LessEqual}; + std::vector operators{Equal, GreaterEqual, LessEqual}; std::vector bounds{0.0, -2.0, 0.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1573,16 +1561,15 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector operators{NumberNode::Equal, - NumberNode::GreaterEqual}; + std::vector operators{Equal, GreaterEqual}; std::vector bounds{23.0, 14.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, operators, bounds}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - NumberNode::BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1618,17 +1605,15 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{ - NumberNode::Equal, NumberNode::LessEqual, NumberNode::GreaterEqual}; + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{11.0, 2.0, 5.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, operators, bounds}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(inode_ptr->axis_wise_bounds().size() == 1); - const NumberNode::BoundAxisInfo inode_bound_axis_ptr = - inode_ptr->axis_wise_bounds().data()[0]; + const BoundAxisInfo inode_bound_axis_ptr = inode_ptr->axis_wise_bounds().data()[0]; CHECK(bound_axes[0].axis == inode_bound_axis_ptr.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(inode_bound_axis_ptr.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(inode_bound_axis_ptr.bounds)); From a996d11aa74dc8c007985bf9bb86b4e3ee83ec24 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 2 Feb 2026 13:06:19 -0800 Subject: [PATCH 10/22] NumberNode bound_axis arg. is no longer optional Also made BoundAxisOperaters an `enum` as opposed to `enum class` because we found a Cython workaround. --- .../dwave-optimization/nodes/numbers.hpp | 55 ++++++++-------- dwave/optimization/src/nodes/numbers.cpp | 66 +++++++++---------- tests/cpp/nodes/test_numbers.cpp | 6 +- 3 files changed, 62 insertions(+), 65 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index fa046987..27400962 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -29,7 +29,7 @@ namespace dwave::optimization { class NumberNode : public ArrayOutputMixin, public DecisionNode { public: /// Allowable axis-wise bound operators. - enum class BoundAxisOperator { Equal, LessEqual, GreaterEqual }; + enum BoundAxisOperator { Equal, LessEqual, GreaterEqual }; /// Struct for stateless axis-wise bound information. Given an `axis`, define /// constraints on the sum of the values in each slice along `axis`. @@ -149,7 +149,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { protected: explicit NumberNode(std::span shape, std::vector lower_bound, std::vector upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); /// Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; @@ -190,40 +190,39 @@ class IntegerNode : public NumberNode { IntegerNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(std::span shape, std::optional> lower_bound, - double upper_bound, - std::optional> bound_axes = std::nullopt); + double upper_bound, std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); // Overloads needed by the Node ABC *************************************** @@ -258,40 +257,38 @@ class BinaryNode : public IntegerNode { BinaryNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(std::span shape, std::optional> lower_bound, - double upper_bound, - std::optional> bound_axes = std::nullopt); + double upper_bound, std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound, - double upper_bound, - std::optional> bound_axes = std::nullopt); + double upper_bound, std::vector bound_axes = {}); BinaryNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::optional> bound_axes = std::nullopt); + std::vector bound_axes = {}); // Flip the value (0 -> 1 or 1 -> 0) at index i in the given state. void flip(State& state, ssize_t i) const; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index a63bbd12..c9f5d2e7 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -142,13 +142,13 @@ bool satisfies_axis_wise_bounds(const std::vector& bo for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); slice < stop_slice; ++slice) { switch (bound_axis_info.get_operator(slice)) { - case NumberNode::BoundAxisOperator::Equal: + case NumberNode::Equal: if (bound_axis_sums[slice] != bound_axis_info.get_bound(slice)) return false; break; - case NumberNode::BoundAxisOperator::LessEqual: + case NumberNode::LessEqual: if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) return false; break; - case NumberNode::BoundAxisOperator::GreaterEqual: + case NumberNode::GreaterEqual: if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; break; default: @@ -227,15 +227,15 @@ std::vector undo_shift_axis_data(const std::span span, c double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, const NumberNode::BoundAxisOperator op, const double bound) { switch (op) { - case NumberNode::BoundAxisOperator::Equal: + case NumberNode::Equal: if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); // If error was not thrown, return amount needed to satisfy bound. return bound - sum; - case NumberNode::BoundAxisOperator::LessEqual: + case NumberNode::LessEqual: if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); // If error was not thrown, sum satisfies bound. return 0.0; - case NumberNode::BoundAxisOperator::GreaterEqual: + case NumberNode::GreaterEqual: // If sum is less than bound, return the amount needed to equal it. // Otherwise, sum satisfies bound. return (sum < bound) ? (bound - sum) : 0.0; @@ -517,14 +517,14 @@ void check_axis_wise_bounds(const std::vector& bound_ // Base class to be used as interfaces. NumberNode::NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound, - std::optional> bound_axes) + std::vector upper_bound, std::vector bound_axes) : ArrayOutputMixin(shape), min_(get_extreme_index_wise_bound(lower_bound)), max_(get_extreme_index_wise_bound(upper_bound)), lower_bounds_(std::move(lower_bound)), upper_bounds_(std::move(upper_bound)), - bound_axes_info_(bound_axes ? std::move(*bound_axes) : std::vector{}) { + bound_axes_info_(bound_axes.size() > 0 ? std::move(bound_axes) + : std::vector{}) { if ((shape.size() > 0) && (shape[0] < 0)) { throw std::invalid_argument("Number array cannot have dynamic size."); } @@ -582,7 +582,7 @@ void check_integrality_of_axis_wise_bounds( IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : NumberNode(shape, lower_bound.has_value() ? std::move(*lower_bound) : std::vector{default_lower_bound}, @@ -599,56 +599,56 @@ IntegerNode::IntegerNode(std::span shape, IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode({size}, std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(shape, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode({size}, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(shape, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::optional> bound_axes) + double upper_bound, std::vector bound_axes) : IntegerNode({size}, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, - double upper_bound, std::optional> bound_axes) + double upper_bound, std::vector bound_axes) : IntegerNode(std::span(shape), std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} @@ -710,63 +710,63 @@ std::vector limit_bound_to_bool_domain(std::optional BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : IntegerNode(shape, limit_bound_to_bool_domain(lower_bound), limit_bound_to_bool_domain(upper_bound), bound_axes) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode({size}, std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(shape, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode({size}, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(shape, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::optional> bound_axes) + double upper_bound, std::vector bound_axes) : BinaryNode({size}, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::optional> bound_axes) + std::vector bound_axes) : BinaryNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index a912542a..599510bd 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -28,9 +28,9 @@ namespace dwave::optimization { using BoundAxisInfo = NumberNode::BoundAxisInfo; using BoundAxisOperator = NumberNode::BoundAxisOperator; -using NumberNode::BoundAxisOperator::Equal; -using NumberNode::BoundAxisOperator::GreaterEqual; -using NumberNode::BoundAxisOperator::LessEqual; +using NumberNode::Equal; +using NumberNode::GreaterEqual; +using NumberNode::LessEqual; TEST_CASE("BoundAxisInfo") { GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { From 99d693d54fa520158f76728d0cf7ccec7451d068 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 11:08:40 -0800 Subject: [PATCH 11/22] NumberNode checks feasibility of axis-wise bounds at construction. --- dwave/optimization/src/nodes/numbers.cpp | 23 ++++--- tests/cpp/nodes/test_numbers.cpp | 81 ++++++++++-------------- 2 files changed, 48 insertions(+), 56 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index c9f5d2e7..15da3e04 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -470,10 +470,12 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& } /// Check the user defined axis-wise bounds for NumberNode -void check_axis_wise_bounds(const std::vector& bound_axes_info, - const std::span shape) { +void check_axis_wise_bounds(const NumberNode* node) { + const std::vector& bound_axes_info = node->axis_wise_bounds(); if (bound_axes_info.size() == 0) return; // No bound axes to check. + const std::span shape = node->shape(); + // Used to asses if an axis have been bound multiple times. std::vector axis_bound(shape.size(), false); @@ -513,6 +515,12 @@ void check_axis_wise_bounds(const std::vector& bound_ if (bound_axes_info.size() > 1) { throw std::invalid_argument("Axis-wise bounds are supported for at most one axis."); } + + // There are quicker ways to check whether the axis-wise bounds are feasible. + // For now, we simply check whether we can construct a valid state. + std::vector values; + values.reserve(node->size()); + construct_state_given_exactly_one_bound_axis(node, values); } // Base class to be used as interfaces. @@ -534,7 +542,7 @@ NumberNode::NumberNode(std::span shape, std::vector lower } check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); - check_axis_wise_bounds(bound_axes_info_, this->shape()); + check_axis_wise_bounds(this); } void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, @@ -565,13 +573,12 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, // Integer Node *************************************************************** /// Check the user defined axis-wise bounds for IntegerNode -void check_integrality_of_axis_wise_bounds( - const std::vector& bound_axes_info) { +void check_bound_axes_integrality(const std::vector& bound_axes_info) { if (bound_axes_info.size() == 0) return; // No bound axes to check. for (const NumberNode::BoundAxisInfo& bound_axis_info : bound_axes_info) { for (const double& bound : bound_axis_info.bounds) { - if (bound != std::round(bound)) { + if (bound != std::floor(bound)) { throw std::invalid_argument( "Axis wise bounds for integral number arrays must be intregral."); } @@ -588,12 +595,12 @@ IntegerNode::IntegerNode(std::span shape, : std::vector{default_lower_bound}, upper_bound.has_value() ? std::move(*upper_bound) : std::vector{default_upper_bound}, - std::move(bound_axes)) { + (check_bound_axes_integrality(bound_axes), std::move(bound_axes))) { if (min_ < minimum_lower_bound || max_ > maximum_upper_bound) { throw std::invalid_argument("range provided for integers exceeds supported range"); } - check_integrality_of_axis_wise_bounds(bound_axes_info_); + check_bound_axes_integrality(bound_axes_info_); } IntegerNode::IntegerNode(std::initializer_list shape, diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 599510bd..b4dcbe79 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -598,12 +598,9 @@ TEST_CASE("BinaryNode") { // Each hyperslice along axis 0 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 0) that results in a // sum equal to 5. - graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, - std::nullopt, bound_axes); - - WHEN("We create a state by initialize_state()") { - REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); - } + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes), + "Infeasible axis-wise bounds."); } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { @@ -611,15 +608,12 @@ TEST_CASE("BinaryNode") { std::vector operators{Equal, GreaterEqual}; std::vector bounds{5.0, 7.0}; std::vector bound_axes{{1, operators, bounds}}; - graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, - std::nullopt, bound_axes); - - WHEN("We create a state by initialize_state()") { - // Each hyperslice along axis 1 has size 6. There is no feasible - // assignment to the values in slice 1 (along axis 1) that results in a - // sum greater than or equal to 7. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); - } + // Each hyperslice along axis 1 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 1) that results in a + // sum greater than or equal to 7. + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes), + "Infeasible axis-wise bounds."); } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { @@ -627,15 +621,12 @@ TEST_CASE("BinaryNode") { std::vector operators{Equal, LessEqual}; std::vector bounds{5.0, -1.0}; std::vector bound_axes{{2, operators, bounds}}; - graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, - std::nullopt, bound_axes); - - WHEN("We create a state by initialize_state()") { - // Each hyperslice along axis 2 has size 6. There is no feasible - // assignment to the values in slice 1 (along axis 2) that results in a - // sum less than or equal to -1. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); - } + // Each hyperslice along axis 2 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 2) that results in a + // sum less than or equal to -1. + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, bound_axes), + "Infeasible axis-wise bounds."); } GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 0") { @@ -1428,14 +1419,12 @@ TEST_CASE("IntegerNode") { std::vector operators{Equal, LessEqual}; std::vector bounds{5.0, -31.0}; std::vector bound_axes{{0, operators, bounds}}; - graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); - - WHEN("We create a state by initialize_state()") { - // Each hyperslice along axis 0 has size 6. There is no feasible - // assignment to the values in slice 1 (along axis 0) that results in a - // sum less than or equal to -5*6-1 = -31. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); - } + // Each hyperslice along axis 0 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 0) that results in a + // sum less than or equal to -5*6-1 = -31. + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes), + "Infeasible axis-wise bounds."); } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { @@ -1443,14 +1432,12 @@ TEST_CASE("IntegerNode") { std::vector operators{GreaterEqual, Equal, Equal}; std::vector bounds{33.0, 0.0, 0.0}; std::vector bound_axes{{1, operators, bounds}}; - graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); - - WHEN("We create a state by initialize_state()") { - // Each hyperslice along axis 1 has size 4. There is no feasible - // assignment to the values in slice 0 (along axis 1) that results in a - // sum greater than or equal to 4*8+1 = 33. - REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); - } + // Each hyperslice along axis 1 has size 4. There is no feasible + // assignment to the values in slice 0 (along axis 1) that results in a + // sum greater than or equal to 4*8+1 = 33. + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes), + "Infeasible axis-wise bounds."); } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { @@ -1458,14 +1445,12 @@ TEST_CASE("IntegerNode") { std::vector operators{GreaterEqual, Equal}; std::vector bounds{-1.0, 49.0}; std::vector bound_axes{{2, operators, bounds}}; - graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); - - WHEN("We create a state by initialize_state()") { - // Each hyperslice along axis 2 has size 6. There is no feasible - // assignment to the values in slice 1 (along axis 2) that results in a - // sum or equal to 6*8+1 = 49 - REQUIRE_THROWS_WITH(graph.initialize_state(), "Infeasible axis-wise bounds."); - } + // Each hyperslice along axis 2 has size 6. There is no feasible + // assignment to the values in slice 1 (along axis 2) that results in a + // sum or equal to 6*8+1 = 49 + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 2}, + -5, 8, bound_axes), + "Infeasible axis-wise bounds."); } GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { From af2184ea08d45e7ea2193579fc0387a83b237690 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 13:01:02 -0800 Subject: [PATCH 12/22] Correct BoundAxisInfo get_bound and get_operator --- dwave/optimization/src/nodes/numbers.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 15da3e04..fa7bd38f 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -50,14 +50,14 @@ NumberNode::BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, double NumberNode::BoundAxisInfo::get_bound(const ssize_t slice) const { assert(0 <= slice); - if (bounds.size() == 0) return bounds[0]; + if (bounds.size() == 1) return bounds[0]; assert(slice < static_cast(bounds.size())); return bounds[slice]; } NumberNode::BoundAxisOperator NumberNode::BoundAxisInfo::get_operator(const ssize_t slice) const { assert(0 <= slice); - if (operators.size() == 0) return operators[0]; + if (operators.size() == 1) return operators[0]; assert(slice < static_cast(operators.size())); return operators[slice]; } From 91b244d9725e413b96c5100e5cc43a246769cc07 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 13:25:03 -0800 Subject: [PATCH 13/22] Expose NumberNode axis-wise bounds to Python --- dwave/optimization/libcpp/nodes/numbers.pxd | 29 +++- dwave/optimization/model.py | 75 +++++++++- dwave/optimization/symbols/numbers.pyx | 116 +++++++++++++++- tests/test_symbols.py | 143 +++++++++++++++++++- 4 files changed, 340 insertions(+), 23 deletions(-) diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index 0f08a25b..f5b6e0b9 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -19,16 +19,31 @@ from dwave.optimization.libcpp.state cimport State cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimization" nogil: - cdef cppclass IntegerNode(ArrayNode): - void initialize_state(State&, vector[double]) except+ - double lower_bound(Py_ssize_t index) - double upper_bound(Py_ssize_t index) - double lower_bound() except+ - double upper_bound() except+ - cdef cppclass BinaryNode(ArrayNode): + cdef cppclass NumberNode(ArrayNode): + enum BoundAxisOperator : + # It appears Cython automatically assumes all (standard) enums are "public" + # hence we override here. + Equal "dwave::optimization::NumberNode::BoundAxisOperator::Equal" + LessEqual "dwave::optimization::NumberNode::BoundAxisOperator::LessEqual" + GreaterEqual "dwave::optimization::NumberNode::BoundAxisOperator::GreaterEqual" + + struct BoundAxisInfo: + BoundAxisInfo(Py_ssize_t axis, vector[BoundAxisOperator] axis_opertors, + vector[double] axis_bounds) + Py_ssize_t axis + vector[BoundAxisOperator] operators; + vector[double] bounds; + void initialize_state(State&, vector[double]) except+ double lower_bound(Py_ssize_t index) double upper_bound(Py_ssize_t index) double lower_bound() except+ double upper_bound() except+ + const vector[BoundAxisInfo] axis_wise_bounds() + + cdef cppclass IntegerNode(NumberNode): + pass + + cdef cppclass BinaryNode(IntegerNode): + pass diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index ea0f92f2..485c4e12 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -165,7 +165,8 @@ def objective(self, value: ArraySymbol): def binary(self, shape: None | _ShapeLike = None, lower_bound: None | np.typing.ArrayLike = None, - upper_bound: None | np.typing.ArrayLike = None) -> BinaryVariable: + upper_bound: None | np.typing.ArrayLike = None, + subject_to: None | np.typing.ArrayLike = None) -> BinaryVariable: r"""Create a binary symbol as a decision variable. Args: @@ -178,6 +179,19 @@ def binary(self, shape: None | _ShapeLike = None, scalar (one bound for all variables) or an array (one bound for each variable). Non-boolean values are rounded down to the domain [0,1]. If None, the default value of 1 is used. + subject_to (optional): Axis-wise bounds for the symbol. Must be an + array of tuples (at most one per axis). Each tuple is of the + form: (axis, operator(s), bound(s)) where + axis (int): Axis in which to apply the bound. + operator(s) (str | array[str]): Operator ("<=", "==", or + ">=") for all hyperslice along axis (str) or per + hyperslice along axis (array[str]). + bound(s) (float | array[float]): Bounds for all + hyperslice along axis (float) or per hyperslice along + axis (array[float]). + If provided, the sum of the values within each hyperslice along + each bound axis will satisfy the axis-wise bounds. + Note: At most one axis-wise bound may be provided. Returns: A binary symbol. @@ -215,15 +229,36 @@ def binary(self, shape: None | _ShapeLike = None, >>> np.all([1, 0] == b.upper_bound()) True + This example adds a :math:`2`-sized binary symbol with a scalar lower + bound and index-wise upper bounds to a model. + + >>> from dwave.optimization.model import Model + >>> import numpy as np + >>> model = Model() + >>> b = model.binary(2, lower_bound=-1.1, upper_bound=[1.1, 0.9]) + >>> np.all([0, 0] == b.lower_bound()) + True + >>> np.all([1, 0] == b.upper_bound()) + True + + This example adds a :math:`(2x3)`-sized binary symbol with index-wise + lower bounds and an axis-wise bound along axis 1. + + >>> from dwave.optimization.model import Model + >>> import numpy as np + >>> model = Model() + >>> i = model.binary([2,3], lower_bound=[[0, 1, 1], [0, 1, 0]], + ... subject_to=[(1, ["<=", "==", ">="], [0, 2, 1])]) + See Also: :class:`~dwave.optimization.symbols.numbers.BinaryVariable`: equivalent symbol. - .. versionchanged:: 0.6.7 - Beginning in version 0.6.7, user-defined bounds and index-wise - bounds are supported. + .. versionchanged:: 0.6.12 + Beginning in version 0.6.12, user-defined axis-wise bounds are + supported. """ from dwave.optimization.symbols import BinaryVariable # avoid circular import - return BinaryVariable(self, shape, lower_bound, upper_bound) + return BinaryVariable(self, shape, lower_bound, upper_bound, subject_to) def constant(self, array_like: numpy.typing.ArrayLike) -> Constant: r"""Create a constant symbol. @@ -478,6 +513,7 @@ def integer( shape: None | _ShapeLike = None, lower_bound: None | numpy.typing.ArrayLike = None, upper_bound: None | numpy.typing.ArrayLike = None, + subject_to: None | np.typing.ArrayLike = None ) -> IntegerVariable: r"""Create an integer symbol as a decision variable. @@ -491,6 +527,19 @@ def integer( scalar (one bound for all variables) or an array (one bound for each variable). Non-integer values are down up. If None, the default value is used. + subject_to (optional): Axis-wise bounds for the symbol. Must be an + array of tuples (at most one per axis). Each tuple is of the + form: (axis, operator(s), bound(s)) where + axis (int): Axis in which to apply the bound. + operator(s) (str | array[str]): Operator ("<=", "==", or + ">=") for all hyperslice along axis (str) or per + hyperslice along axis (array[str]). + bound(s) (float | array[float]): Bounds for all + hyperslice along axis (float) or per hyperslice along + axis (array[float]). + If provided, the sum of the values within each hyperslice along + each bound axis will satisfy the axis-wise bounds. + Note: At most one axis-wise bound may be provided. Returns: An integer symbol. @@ -529,15 +578,29 @@ def integer( >>> np.all([1, 2] == i.upper_bound()) True + This example adds a :math:`(2x3)`-sized integer symbol with + general lower and upper bounds and an axis-wise bound along + axis 1. + + >>> from dwave.optimization.model import Model + >>> import numpy as np + >>> model = Model() + >>> i = model.integer([2,3], lower_bound=1, upper_bound=3, + ... subject_to=[(1, "<=", [2, 4, 5])]) + See Also: :class:`~dwave.optimization.symbols.numbers.IntegerVariable`: equivalent symbol. .. versionchanged:: 0.6.7 Beginning in version 0.6.7, user-defined index-wise bounds are supported. + + .. versionchanged:: 0.6.12 + Beginning in version 0.6.12, user-defined axis-wise bounds are + supported. """ from dwave.optimization.symbols import IntegerVariable # avoid circular import - return IntegerVariable(self, shape, lower_bound, upper_bound) + return IntegerVariable(self, shape, lower_bound, upper_bound, subject_to) def list(self, n: int, diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 0f98f530..ad5c45a1 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -16,6 +16,7 @@ import json +import collections.abc import numpy as np from cython.operator cimport typeid @@ -27,25 +28,98 @@ from dwave.optimization._model cimport _Graph, _register, ArraySymbol, Symbol from dwave.optimization._utilities cimport as_cppshape from dwave.optimization.libcpp cimport dynamic_cast_ptr from dwave.optimization.libcpp.nodes.numbers cimport ( + NumberNode, BinaryNode, IntegerNode, ) from dwave.optimization.states cimport States +cdef NumberNode.BoundAxisOperator _parse_python_operator(str op) except *: + if op == "==": + return NumberNode.BoundAxisOperator.Equal + elif op == "<=": + return NumberNode.BoundAxisOperator.LessEqual + elif op == ">=": + return NumberNode.BoundAxisOperator.GreaterEqual + else: + raise TypeError(f"Invalid bound axis operator: {op!r}") + + +cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( + bound_axes_data : None | list[tuple(int, str | list[str], float | list[float])]) except *: + cdef vector[NumberNode.BoundAxisInfo] output + + if bound_axes_data is None: + return output + + output.reserve(len(bound_axes_data)) + cdef vector[NumberNode.BoundAxisOperator] cpp_ops + cdef vector[double] cpp_bounds + cdef double[:] mem + + for bound_axis_data in bound_axes_data: + if not isinstance(bound_axis_data, tuple) or len(bound_axis_data) != 3: + raise TypeError("Each bound axis entry must be a tuple: (axis, operator(s), bound(s))") + + if not isinstance(bound_axis_data[0], int): + raise TypeError("Bound axis must be an int.") + + axis, py_ops, py_bounds = bound_axis_data + + cpp_ops.clear() + if isinstance(py_ops, str): + cpp_ops.push_back(_parse_python_operator(py_ops)) + else: + ops_array = np.asarray(py_ops, order='C') + if (ops_array.ndim <= 1): + cpp_ops.reserve(ops_array.size) + for op in ops_array: + cpp_ops.push_back(_parse_python_operator(str(op))) + else: + raise TypeError("Bound axis operator(s) should be str or 1D-array of str.") + + cpp_bounds.clear() + bound_array = np.asarray_chkfinite(py_bounds, dtype=np.double, order='C') + if (bound_array.ndim <= 1): + mem = bound_array.ravel() + cpp_bounds.reserve(mem.shape[0]) + for i in range(mem.shape[0]): + cpp_bounds.push_back(mem[i]) + else: + raise TypeError("Bound axis bound(s) should be scalar or 1D-array.") + + output.push_back(NumberNode.BoundAxisInfo(axis, cpp_ops, cpp_bounds)) + + return output + + +cdef str _parse_cpp_operators(NumberNode.BoundAxisOperator op): + if op == NumberNode.BoundAxisOperator.Equal: + return "==" + elif op == NumberNode.BoundAxisOperator.LessEqual: + return "<=" + elif op == NumberNode.BoundAxisOperator.GreaterEqual: + return ">=" + else: + raise ValueError(f"Invalid bound axis operator: {op!r}") + + cdef class BinaryVariable(ArraySymbol): """Binary decision-variable symbol. See also: :meth:`~dwave.optimization.model.Model.binary`: equivalent method. """ - def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None): + def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, + subject_to=None): cdef vector[Py_ssize_t] cppshape = as_cppshape( tuple() if shape is None else shape ) cdef optional[vector[double]] cpplower_bound = nullopt cdef optional[vector[double]] cppupper_bound = nullopt + cdef vector[BinaryNode.BoundAxisInfo] cppbound_axes = _convert_python_bound_axes(subject_to) cdef const double[:] mem if lower_bound is not None: @@ -75,7 +149,7 @@ cdef class BinaryVariable(ArraySymbol): raise ValueError("upper bound should be None, scalar, or the same shape") self.ptr = model._graph.emplace_node[BinaryNode]( - cppshape, cpplower_bound, cppupper_bound + cppshape, cpplower_bound, cppupper_bound, cppbound_axes ) self.initialize_arraynode(model, self.ptr) @@ -143,6 +217,22 @@ cdef class BinaryVariable(ArraySymbol): with zf.open(directory + "upper_bound.npy", mode="w", force_zip64=True) as f: np.save(f, upper_bound, allow_pickle=False) + def axis_wise_bounds(self): + """Axis wise bound(s) of Binary symbol as a list of tuples where + each tuple is of the form: (axis, [operator(s)], [bound(s)]).""" + cdef vector[NumberNode.BoundAxisInfo] bound_axes = self.ptr.axis_wise_bounds() + + output = [] + for i in range(bound_axes.size()): + bound_axis = &bound_axes[i] + py_axis_ops = [_parse_cpp_operators(bound_axis.operators[j]) + for j in range(bound_axis.operators.size())] + py_axis_bounds = [bound_axis.bounds[j] for j in range(bound_axis.bounds.size())] + + output.append((bound_axis.axis, py_axis_ops, py_axis_bounds)) + + return output + def lower_bound(self): """Lower bound(s) of Binary symbol.""" try: @@ -212,13 +302,15 @@ cdef class IntegerVariable(ArraySymbol): See Also: :meth:`~dwave.optimization.model.Model.integer`: equivalent method. """ - def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None): + def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, + subject_to=None): cdef vector[Py_ssize_t] cppshape = as_cppshape( tuple() if shape is None else shape ) cdef optional[vector[double]] cpplower_bound = nullopt cdef optional[vector[double]] cppupper_bound = nullopt + cdef vector[BinaryNode.BoundAxisInfo] cppbound_axes = _convert_python_bound_axes(subject_to) cdef const double[:] mem if lower_bound is not None: @@ -248,7 +340,7 @@ cdef class IntegerVariable(ArraySymbol): raise ValueError("upper bound should be None, scalar, or the same shape") self.ptr = model._graph.emplace_node[IntegerNode]( - cppshape, cpplower_bound, cppupper_bound + cppshape, cpplower_bound, cppupper_bound, cppbound_axes ) self.initialize_arraynode(model, self.ptr) @@ -322,6 +414,22 @@ cdef class IntegerVariable(ArraySymbol): with zf.open(directory + "upper_bound.npy", mode="w", force_zip64=True) as f: np.save(f, upper_bound, allow_pickle=False) + def axis_wise_bounds(self): + """Axis wise bound(s) of Integer symbol as a list of tuples where + each tuple is of the form: (axis, [operator(s)], [bound(s)]).""" + cdef vector[NumberNode.BoundAxisInfo] bound_axes = self.ptr.axis_wise_bounds() + + output = [] + for i in range(bound_axes.size()): + bound_axis = &bound_axes[i] + py_axis_ops = [_parse_cpp_operators(bound_axis.operators[j]) + for j in range(bound_axis.operators.size())] + py_axis_bounds = [bound_axis.bounds[j] for j in range(bound_axis.bounds.size())] + + output.append((bound_axis.axis, py_axis_ops, py_axis_bounds)) + + return output + def lower_bound(self): """Lower bound(s) of Integer symbol.""" try: diff --git a/tests/test_symbols.py b/tests/test_symbols.py index f540f061..1cc6b1a1 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -711,7 +711,7 @@ def test(self): model.binary([10]) - def test_bounds(self): + def test_index_wise_bounds(self): model = Model() x = model.binary(lower_bound=0, upper_bound=1) self.assertEqual(x.lower_bound(), 0) @@ -725,10 +725,51 @@ def test_bounds(self): self.assertTrue(np.all(x.upper_bound() == [[1, 0, 0], [1, 0, 0]])) with self.assertRaises(ValueError): - model.integer((2, 3), upper_bound=np.nan) + model.binary((2, 3), upper_bound=np.nan) with self.assertRaises(ValueError): - model.integer((2, 3), upper_bound=np.arange(6)) + model.binary((2, 3), upper_bound=np.arange(6)) + + def test_axis_wise_bounds(self): + model = Model() + + # stores correct axis-wise bounds + x = model.binary((2, 3), subject_to=[(0, ["<=", "=="], [1, 2])]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) + x = model.binary((2, 3), subject_to=[(1, "<=", [1, 2, 1])]) + self.assertEqual(x.axis_wise_bounds(), [(1, ["<="], [1, 2, 1])]) + x = model.binary((2, 3), subject_to=[(0, ["<=", "=="], 1)]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1])]) + x = model.binary((2, 3), subject_to=[(0, "<=", 1)]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<="], [1])]) + x = model.binary((2, 3), subject_to=[(0, np.asarray(["<=", "=="]), np.asarray([1, 2]))]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) + + # infeasible axis-wise bounds + with self.assertRaises(ValueError): + model.binary((2, 3), lower_bound=[0, 1, 0, 0, 1, 0], subject_to=[(0, "==", 0)]) + with self.assertRaises(ValueError): + model.binary((2, 3), lower_bound=[0, 1, 0, 0, 1, 0], subject_to=[(0, "<=", 0)]) + with self.assertRaises(ValueError): + model.binary((2, 3), upper_bound=[0, 1, 0, 0, 1, 0], subject_to=[(0, ">=", 2)]) + + # incorrect number of axis-wise operators and or bounds + with self.assertRaises(ValueError): + model.binary((2, 3), subject_to=[(0, "==", [0, 0, 0])]) + with self.assertRaises(ValueError): + model.binary((2, 3), subject_to=[(0, ["==", "<=", "=="], [0, 0])]) + + # check bad argument format + with self.assertRaises(TypeError): + model.binary((2, 3), subject_to=[(1.1, "<=", [0, 0, 0])]) + with self.assertRaises(TypeError): + model.binary((2, 3), subject_to=[(1, 4, [0, 0, 0])]) + with self.assertRaises(TypeError): + model.binary((2, 3), subject_to=[(1, ["!="], [0, 0, 0])]) + with self.assertRaises(TypeError): + model.binary((2, 3), subject_to=[(1, ["=="], [[0, 0, 0]])]) + with self.assertRaises(TypeError): + model.binary((2, 3), subject_to=[(1, [["<="]], [0, 0, 0])]) def test_no_shape(self): model = Model() @@ -798,7 +839,7 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(0, 2) - with self.subTest("Simple bounds test"): + with self.subTest("Simple index-wise bounds test"): model = Model() model.states.resize(1) x = model.binary(2, lower_bound=[-1, 0.9], upper_bound=[1.1, 1.2]) @@ -808,6 +849,25 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(1, 0) + with self.subTest("Simple axis-wise bounds test"): + model = Model() + model.states.resize(1) + x = model.binary((2, 3), subject_to=[(0, "==", 1)]) + x.set_state(0, [0, 1, 0, 1, 0, 0]) + # Do not satisfy axis-wise bounds + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 1, 0, 1, 0, 0]) + with np.testing.assert_raises(ValueError): + x.set_state(0, [0, 1, 0, 0, 0, 0]) + + x = model.binary((2, 2), subject_to=[(1, ["<=", ">="], [0, 2])]) + x.set_state(0, [0, 1, 0, 1]) + # Do not satisfy axis-wise bounds + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 1, 0, 1]) + with np.testing.assert_raises(ValueError): + x.set_state(0, [0, 0, 0, 1]) + with self.subTest("invalid state index"): model = Model() x = model.binary(5) @@ -1830,7 +1890,7 @@ def test_no_shape(self): model.states.resize(1) self.assertEqual(x.state(0).shape, tuple()) - def test_bounds(self): + def test_index_wise_bounds(self): model = Model() x = model.integer(lower_bound=4, upper_bound=5) self.assertEqual(x.lower_bound(), 4) @@ -1849,6 +1909,47 @@ def test_bounds(self): with self.assertRaises(ValueError): model.integer((2, 3), upper_bound=np.arange(6)) + def test_axis_wise_bounds(self): + model = Model() + + # stores correct axis-wise bounds + x = model.integer((2, 3), subject_to=[(0, ["<=", "=="], [1, 2])]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) + x = model.integer((2, 3), subject_to=[(1, "<=", [1, 2, 1])]) + self.assertEqual(x.axis_wise_bounds(), [(1, ["<="], [1, 2, 1])]) + x = model.integer((2, 3), subject_to=[(0, ["<=", "=="], 1)]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1])]) + x = model.integer((2, 3), subject_to=[(0, "<=", 1)]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<="], [1])]) + x = model.integer((2, 3), subject_to=[(0, np.asarray(["<=", "=="]), np.asarray([1, 2]))]) + self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) + + # infeasible axis-wise bounds + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[(0, "==", -1)]) + with self.assertRaises(ValueError): + model.integer((2, 3), lower_bound=0, subject_to=[(0, "<=", -1)]) + with self.assertRaises(ValueError): + model.integer((2, 3), upper_bound=2, subject_to=[(0, ">=", 7)]) + + # incorrect number of axis-wise operators and or bounds + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[(0, "==", [10, 20, 30])]) + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[(0, ["==", "<=", "=="], [10, 20])]) + + # bad argument format + with self.assertRaises(TypeError): + model.integer((2, 3), subject_to=[(1.1, "<=", [0, 0, 0])]) + with self.assertRaises(TypeError): + model.integer((2, 3), subject_to=[(1, 4, [0, 0, 0])]) + with self.assertRaises(TypeError): + model.integer((2, 3), subject_to=[(1, ["!="], [0, 0, 0])]) + with self.assertRaises(TypeError): + model.integer((2, 3), subject_to=[(1, ["=="], [[0, 0, 0]])]) + with self.assertRaises(TypeError): + model.integer((2, 3), subject_to=[(1, [["=="]], [0, 0, 0])]) + # Todo: we can generalize many of these tests for all decisions that can have # their state set @@ -1904,7 +2005,7 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(0, -1234) - with self.subTest("Simple bounds test"): + with self.subTest("Simple index-wise bounds test"): model = Model() model.states.resize(1) x = model.integer(1, lower_bound=-1, upper_bound=1) @@ -1915,6 +2016,25 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(0, -2) + with self.subTest("Simple axis-wise bounds test"): + model = Model() + model.states.resize(1) + x = model.integer((2, 3), subject_to=[(0, "==", 3)]) + x.set_state(0, [0, 3, 0, 1, 1, 1]) + # Do not satisfy axis-wise bounds + with np.testing.assert_raises(ValueError): + x.set_state(0, [0, 3, 1, 1, 1, 1]) + with np.testing.assert_raises(ValueError): + x.set_state(0, [0, 3, 0, 1, 1, 0]) + + x = model.integer((2, 2), subject_to=[(1, ["<=", ">="], [2, 6])]) + x.set_state(0, [1, 6, 1, 10]) + # Do not satisfy axis-wise bounds + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 2, 1, 1]) + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 6, 2, 10]) + with self.subTest("array-like"): model = Model() model.states.resize(1) @@ -1943,6 +2063,17 @@ def test_set_state(self): x.set_state(0, [-0.5, -0.75, -0.5, -1.0, -0.1]) np.testing.assert_array_equal(x.state(), [0, 0, 0, -1, 0]) + # with self.subTest("Axis-wise bounds"): + # model = Model() + # model.states.resize(1) + # x = model.integer([2, 3], lower_bound=0, upper_bound=2, + # subject_to=[(0, "==", 0)]) + # x.set_state(0, [0, 0, 1, 0, 1, 0]) + # # with np.testing.assert_raises(ValueError): + # # x.set_state(0, 2) + # # with np.testing.assert_raises(ValueError): + # # x.set_state(0, -2) + class TestIsIn(utils.SymbolTests): def generate_symbols(self): From 6931a75a8a0542bbe2e67ff697c4f40bbd397f11 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:01:43 -0800 Subject: [PATCH 14/22] Enabled zip/unzip of axis-wise bounds on NumberNode --- dwave/optimization/model.py | 8 ++--- dwave/optimization/symbols/numbers.pyx | 42 ++++++++++++++++++++++---- tests/test_symbols.py | 6 ++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 485c4e12..2800eb60 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -180,8 +180,8 @@ def binary(self, shape: None | _ShapeLike = None, each variable). Non-boolean values are rounded down to the domain [0,1]. If None, the default value of 1 is used. subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples (at most one per axis). Each tuple is of the - form: (axis, operator(s), bound(s)) where + array of tuples or lists (at most one per axis). Each + tuple/list is of the form: (axis, operator(s), bound(s)) where axis (int): Axis in which to apply the bound. operator(s) (str | array[str]): Operator ("<=", "==", or ">=") for all hyperslice along axis (str) or per @@ -528,8 +528,8 @@ def integer( each variable). Non-integer values are down up. If None, the default value is used. subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples (at most one per axis). Each tuple is of the - form: (axis, operator(s), bound(s)) where + array of tuples or lists (at most one per axis). Each + tuple/list is of the form: (axis, operator(s), bound(s)) where axis (int): Axis in which to apply the bound. operator(s) (str | array[str]): Operator ("<=", "==", or ">=") for all hyperslice along axis (str) or per diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index ad5c45a1..f0b12a8e 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -59,14 +59,15 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( cdef double[:] mem for bound_axis_data in bound_axes_data: - if not isinstance(bound_axis_data, tuple) or len(bound_axis_data) != 3: - raise TypeError("Each bound axis entry must be a tuple: (axis, operator(s), bound(s))") - - if not isinstance(bound_axis_data[0], int): - raise TypeError("Bound axis must be an int.") + if not isinstance(bound_axis_data, (tuple, list)) or len(bound_axis_data) != 3: + raise TypeError("Each bound axis entry must be a tuple or list with" + " three elements: axis, operator(s), bound(s)") axis, py_ops, py_bounds = bound_axis_data + if not isinstance(axis, int): + raise TypeError("Bound axis must be an int.") + cpp_ops.clear() if isinstance(py_ops, str): cpp_ops.push_back(_parse_python_operator(py_ops)) @@ -77,7 +78,8 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( for op in ops_array: cpp_ops.push_back(_parse_python_operator(str(op))) else: - raise TypeError("Bound axis operator(s) should be str or 1D-array of str.") + raise TypeError("Bound axis operator(s) should be str or" + " 1D-array of str.") cpp_bounds.clear() bound_array = np.asarray_chkfinite(py_bounds, dtype=np.double, order='C') @@ -190,10 +192,20 @@ cdef class BinaryVariable(ArraySymbol): with zf.open(info, "r") as f: upper_bound = np.load(f, allow_pickle=False) + # needs to be compatible with older versions + try: + info = zf.getinfo(directory + "subject_to.json") + except KeyError: + subject_to = None + else: + with zf.open(info, "r") as f: + subject_to = json.load(f) + return BinaryVariable(model, shape=shape_info["shape"], lower_bound=lower_bound, upper_bound=upper_bound, + subject_to=subject_to ) def _into_zipfile(self, zf, directory): @@ -217,6 +229,10 @@ cdef class BinaryVariable(ArraySymbol): with zf.open(directory + "upper_bound.npy", mode="w", force_zip64=True) as f: np.save(f, upper_bound, allow_pickle=False) + subject_to = self.axis_wise_bounds() + if len(subject_to) > 0: + zf.writestr(directory + "subject_to.json", encoder.encode(subject_to)) + def axis_wise_bounds(self): """Axis wise bound(s) of Binary symbol as a list of tuples where each tuple is of the form: (axis, [operator(s)], [bound(s)]).""" @@ -381,10 +397,20 @@ cdef class IntegerVariable(ArraySymbol): with zf.open(info, "r") as f: upper_bound = np.load(f, allow_pickle=False) + # needs to be compatible with older versions + try: + info = zf.getinfo(directory + "subject_to.json") + except KeyError: + subject_to = None + else: + with zf.open(info, "r") as f: + subject_to = json.load(f) + return IntegerVariable(model, shape=shape_info["shape"], lower_bound=lower_bound, upper_bound=upper_bound, + subject_to=subject_to ) def _into_zipfile(self, zf, directory): @@ -414,6 +440,10 @@ cdef class IntegerVariable(ArraySymbol): with zf.open(directory + "upper_bound.npy", mode="w", force_zip64=True) as f: np.save(f, upper_bound, allow_pickle=False) + subject_to = self.axis_wise_bounds() + if len(subject_to) > 0: + zf.writestr(directory + "subject_to.json", encoder.encode(subject_to)) + def axis_wise_bounds(self): """Axis wise bound(s) of Integer symbol as a list of tuples where each tuple is of the form: (axis, [operator(s)], [bound(s)]).""" diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 1cc6b1a1..1a5ca03a 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -806,6 +806,8 @@ def test_serialization(self): model.binary(), model.binary(3, lower_bound=1), model.binary(2, upper_bound=[0,1]), + model.binary((2, 3), subject_to=[(1, "<=", [0, 1, 2])]), + model.binary((2, 3), subject_to=[(0, ["<=", "=="], 1)]), ] model.lock() @@ -817,6 +819,7 @@ def test_serialization(self): for i in range(old.size()): self.assertTrue(np.all(old.lower_bound() == new.lower_bound())) self.assertTrue(np.all(old.upper_bound() == new.upper_bound())) + self.assertEqual(old.axis_wise_bounds(), new.axis_wise_bounds()) def test_set_state(self): with self.subTest("array-like"): @@ -1970,6 +1973,8 @@ def test_serialization(self): model.integer(upper_bound=105), model.integer(15, lower_bound=4, upper_bound=6), model.integer(2, lower_bound=[1, 2], upper_bound=[3, 4]), + model.integer((2, 3), subject_to=[(1, "<=", [0, 1, 2])]), + model.integer((2, 3), subject_to=[(0, ["<=", ">="], 2)]), ] model.lock() @@ -1981,6 +1986,7 @@ def test_serialization(self): for i in range(old.size()): self.assertTrue(np.all(old.lower_bound() == new.lower_bound())) self.assertTrue(np.all(old.upper_bound() == new.upper_bound())) + self.assertEqual(old.axis_wise_bounds(), new.axis_wise_bounds()) def test_set_state(self): with self.subTest("Simple positive integer"): From ba6e44b040c67a1c09ae7016d2970a93d4f2b4f4 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:07:51 -0800 Subject: [PATCH 15/22] Fixed integer and binary python docs --- dwave/optimization/model.py | 44 +++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 2800eb60..31daebd5 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -180,18 +180,16 @@ def binary(self, shape: None | _ShapeLike = None, each variable). Non-boolean values are rounded down to the domain [0,1]. If None, the default value of 1 is used. subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples or lists (at most one per axis). Each - tuple/list is of the form: (axis, operator(s), bound(s)) where - axis (int): Axis in which to apply the bound. - operator(s) (str | array[str]): Operator ("<=", "==", or - ">=") for all hyperslice along axis (str) or per - hyperslice along axis (array[str]). - bound(s) (float | array[float]): Bounds for all - hyperslice along axis (float) or per hyperslice along - axis (array[float]). - If provided, the sum of the values within each hyperslice along - each bound axis will satisfy the axis-wise bounds. - Note: At most one axis-wise bound may be provided. + array of tuples or lists. Each tuple/list is of the form: + (axis, operator(s), bound(s)) where `axis` (int) is the axis in + which to apply the bound, `operator(s)` (str | array[str]) is + the operator(s) ("<=", "==", or ">=") defined per hyperslice or + for all hyperslice along the bound axis, and `bound(s)` (float + | array[float]) is the bound(s) defined per hyperslice or for + all hyperslice along the bound axis. If provided, the sum of + the values within each hyperslice along each bound axis will + satisfy the axis-wise bounds. Note: At most one axis-wise bound + may be provided. Returns: A binary symbol. @@ -528,18 +526,16 @@ def integer( each variable). Non-integer values are down up. If None, the default value is used. subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples or lists (at most one per axis). Each - tuple/list is of the form: (axis, operator(s), bound(s)) where - axis (int): Axis in which to apply the bound. - operator(s) (str | array[str]): Operator ("<=", "==", or - ">=") for all hyperslice along axis (str) or per - hyperslice along axis (array[str]). - bound(s) (float | array[float]): Bounds for all - hyperslice along axis (float) or per hyperslice along - axis (array[float]). - If provided, the sum of the values within each hyperslice along - each bound axis will satisfy the axis-wise bounds. - Note: At most one axis-wise bound may be provided. + array of tuples or lists. Each tuple/list is of the form: + (axis, operator(s), bound(s)) where `axis` (int) is the axis in + which to apply the bound, `operator(s)` (str | array[str]) is + the operator(s) ("<=", "==", or ">=") defined per hyperslice or + for all hyperslice along the bound axis, and `bound(s)` (float + | array[float]) is the bound(s) defined per hyperslice or for + all hyperslice along the bound axis. If provided, the sum of + the values within each hyperslice along each bound axis will + satisfy the axis-wise bounds. Note: At most one axis-wise bound + may be provided. Returns: An integer symbol. From 15f7b678b2f8d897ad8f564eeb0c22b303d12450 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:09:16 -0800 Subject: [PATCH 16/22] Added release note for axis-wise bounds --- .../notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml diff --git a/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml b/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml new file mode 100644 index 00000000..18239672 --- /dev/null +++ b/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml @@ -0,0 +1,5 @@ +--- +features: + - | + Axis-wise bounds added to NumberNode. Available to both IntegerNode and + BinaryNode. From 71d53686c41123d267467b337fde13afc06114f7 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:24:11 -0800 Subject: [PATCH 17/22] Cleaning NumberNode axis-wise bounds --- .../include/dwave-optimization/nodes/numbers.hpp | 2 +- dwave/optimization/model.py | 4 ++++ dwave/optimization/symbols/numbers.pyx | 7 ++++++- tests/test_symbols.py | 11 ----------- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 27400962..242d70b4 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -96,7 +96,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Initialize the state of the node randomly template void initialize_state(State& state, Generator& rng) const { - // Currently, we do not support random node Initialization with + // Currently, we do not support random node initialization with // axis wise bounds. if (bound_axes_info_.size() > 0) { throw std::invalid_argument("Cannot randomly initialize_state with bound axes"); diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 31daebd5..25a2e354 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -251,6 +251,10 @@ def binary(self, shape: None | _ShapeLike = None, See Also: :class:`~dwave.optimization.symbols.numbers.BinaryVariable`: equivalent symbol. + .. versionchanged:: 0.6.7 + Beginning in version 0.6.7, user-defined index-wise bounds are + supported. + .. versionchanged:: 0.6.12 Beginning in version 0.6.12, user-defined axis-wise bounds are supported. diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index f0b12a8e..57fb4952 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -16,7 +16,6 @@ import json -import collections.abc import numpy as np from cython.operator cimport typeid @@ -59,6 +58,8 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( cdef double[:] mem for bound_axis_data in bound_axes_data: + # We allow lists and tuples because the _from_zipfile method yields + # a list of lists not a list of tuples. if not isinstance(bound_axis_data, (tuple, list)) or len(bound_axis_data) != 3: raise TypeError("Each bound axis entry must be a tuple or list with" " three elements: axis, operator(s), bound(s)") @@ -199,6 +200,7 @@ cdef class BinaryVariable(ArraySymbol): subject_to = None else: with zf.open(info, "r") as f: + # Note that import is a list of lists, not a list of tuples subject_to = json.load(f) return BinaryVariable(model, @@ -231,6 +233,7 @@ cdef class BinaryVariable(ArraySymbol): subject_to = self.axis_wise_bounds() if len(subject_to) > 0: + # Using json here converts the tuples to lists zf.writestr(directory + "subject_to.json", encoder.encode(subject_to)) def axis_wise_bounds(self): @@ -404,6 +407,7 @@ cdef class IntegerVariable(ArraySymbol): subject_to = None else: with zf.open(info, "r") as f: + # Note that import is a list of lists, not a list of tuples subject_to = json.load(f) return IntegerVariable(model, @@ -442,6 +446,7 @@ cdef class IntegerVariable(ArraySymbol): subject_to = self.axis_wise_bounds() if len(subject_to) > 0: + # Using json here converts the tuples to lists zf.writestr(directory + "subject_to.json", encoder.encode(subject_to)) def axis_wise_bounds(self): diff --git a/tests/test_symbols.py b/tests/test_symbols.py index 1a5ca03a..f548ca9b 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -2069,17 +2069,6 @@ def test_set_state(self): x.set_state(0, [-0.5, -0.75, -0.5, -1.0, -0.1]) np.testing.assert_array_equal(x.state(), [0, 0, 0, -1, 0]) - # with self.subTest("Axis-wise bounds"): - # model = Model() - # model.states.resize(1) - # x = model.integer([2, 3], lower_bound=0, upper_bound=2, - # subject_to=[(0, "==", 0)]) - # x.set_state(0, [0, 0, 1, 0, 1, 0]) - # # with np.testing.assert_raises(ValueError): - # # x.set_state(0, 2) - # # with np.testing.assert_raises(ValueError): - # # x.set_state(0, -2) - class TestIsIn(utils.SymbolTests): def generate_symbols(self): From 1c177b4ff8aec8292340a0cc1dfcc3a32dd3bbac Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 16:01:34 -0800 Subject: [PATCH 18/22] Restrict NumberNode _from_zip return type Previously accepted list of tuples and list of lists. Now simply list of tuples for consistency. --- dwave/optimization/model.py | 32 +++++++++++++------------- dwave/optimization/symbols/numbers.pyx | 14 +++++++---- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 25a2e354..d33822de 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -180,14 +180,14 @@ def binary(self, shape: None | _ShapeLike = None, each variable). Non-boolean values are rounded down to the domain [0,1]. If None, the default value of 1 is used. subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples or lists. Each tuple/list is of the form: - (axis, operator(s), bound(s)) where `axis` (int) is the axis in - which to apply the bound, `operator(s)` (str | array[str]) is - the operator(s) ("<=", "==", or ">=") defined per hyperslice or - for all hyperslice along the bound axis, and `bound(s)` (float - | array[float]) is the bound(s) defined per hyperslice or for - all hyperslice along the bound axis. If provided, the sum of - the values within each hyperslice along each bound axis will + array of tuples. Each tuple is of the form: (axis, operator(s), + bound(s)) where `axis` (int) is the axis to apply the bound(s), + `operator(s)` (str | array[str]) is the operator(s) ("<=", + "==", or ">=") defined for all hyperslice or per hyperslice + along the bound axis, and `bound(s)` (float | array[float]) is + the bound(s) defined for all hyperslice or per hyperslice + hyperslice along the bound axis. If provided, the sum of the + values within each hyperslice along each bound axis will satisfy the axis-wise bounds. Note: At most one axis-wise bound may be provided. @@ -530,14 +530,14 @@ def integer( each variable). Non-integer values are down up. If None, the default value is used. subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples or lists. Each tuple/list is of the form: - (axis, operator(s), bound(s)) where `axis` (int) is the axis in - which to apply the bound, `operator(s)` (str | array[str]) is - the operator(s) ("<=", "==", or ">=") defined per hyperslice or - for all hyperslice along the bound axis, and `bound(s)` (float - | array[float]) is the bound(s) defined per hyperslice or for - all hyperslice along the bound axis. If provided, the sum of - the values within each hyperslice along each bound axis will + array of tuples. Each tuple is of the form: (axis, operator(s), + bound(s)) where `axis` (int) is the axis to apply the bound(s), + `operator(s)` (str | array[str]) is the operator(s) ("<=", + "==", or ">=") defined for all hyperslice or per hyperslice + along the bound axis, and `bound(s)` (float | array[float]) is + the bound(s) defined for all hyperslice or per hyperslice + hyperslice along the bound axis. If provided, the sum of the + values within each hyperslice along each bound axis will satisfy the axis-wise bounds. Note: At most one axis-wise bound may be provided. diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 57fb4952..54239828 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -58,10 +58,9 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( cdef double[:] mem for bound_axis_data in bound_axes_data: - # We allow lists and tuples because the _from_zipfile method yields - # a list of lists not a list of tuples. - if not isinstance(bound_axis_data, (tuple, list)) or len(bound_axis_data) != 3: - raise TypeError("Each bound axis entry must be a tuple or list with" + if not isinstance(bound_axis_data, tuple) or len(bound_axis_data) != 3: + print(bound_axis_data) + raise TypeError("Each bound axis entry must be a tuple with" " three elements: axis, operator(s), bound(s)") axis, py_ops, py_bounds = bound_axis_data @@ -200,8 +199,10 @@ cdef class BinaryVariable(ArraySymbol): subject_to = None else: with zf.open(info, "r") as f: - # Note that import is a list of lists, not a list of tuples subject_to = json.load(f) + # Note that import is a list of lists, not a list of tuples, + # hence we convert to tuple. We could also support lists. + subject_to = [(axis, ops, bounds) for axis, ops, bounds in subject_to] return BinaryVariable(model, shape=shape_info["shape"], @@ -409,6 +410,9 @@ cdef class IntegerVariable(ArraySymbol): with zf.open(info, "r") as f: # Note that import is a list of lists, not a list of tuples subject_to = json.load(f) + # Note that import is a list of lists, not a list of tuples, + # hence we convert to tuple. We could also support lists. + subject_to = [(axis, ops, bounds) for axis, ops, bounds in subject_to] return IntegerVariable(model, shape=shape_info["shape"], From 2283b9a351021f94657390b0c53951e974a9b25f Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 10:53:03 -0800 Subject: [PATCH 19/22] Cleaned up C++ code, comments, and tests for NumberNode --- .../dwave-optimization/nodes/numbers.hpp | 24 +- dwave/optimization/src/nodes/numbers.cpp | 91 +++--- tests/cpp/nodes/test_numbers.cpp | 278 ++++++++---------- 3 files changed, 184 insertions(+), 209 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 242d70b4..df938bcf 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -42,10 +42,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::vector axis_bounds); /// The bound axis ssize_t axis; - /// Operator for ALL axis slices (vector has length one) or operator*s* PER + /// Operator for ALL axis slices (vector has length one) or operators PER /// slice (length of vector is equal to the number of slices). std::vector operators; - /// Bound for ALL axis slices (vector has length one) or bound*s* PER slice + /// Bound for ALL axis slices (vector has length one) or bounds PER slice /// (length of vector is equal to the number of slices). std::vector bounds; @@ -96,10 +96,9 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Initialize the state of the node randomly template void initialize_state(State& state, Generator& rng) const { - // Currently, we do not support random node initialization with - // axis wise bounds. + // Currently do not support random node initialization with bound axes. if (bound_axes_info_.size() > 0) { - throw std::invalid_argument("Cannot randomly initialize_state with bound axes"); + throw std::invalid_argument("Cannot randomly initialize_state with bound axes."); } std::vector values; @@ -140,10 +139,11 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // in a given index. void clip_and_set_value(State& state, ssize_t index, double value) const; - /// Return vector of axis-wise bounds. + /// Return the stateless axis-wise bound information i.e. bound_axes_info_. const std::vector& axis_wise_bounds() const; - /// Return vector containing the bound axis sums in a given state. + /// Return the state-dependent sum of the values within each hyperslice + /// along each bound axis. const std::vector>& bound_axis_sums(State& state) const; protected: @@ -151,10 +151,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::vector upper_bound, std::vector bound_axes = {}); - /// Return truth statement: 'value is valid in a given index'. + // Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; - /// Default value in a given index. + // Default value in a given index. virtual double default_value(ssize_t index) const = 0; /// Update the running bound axis sums where the value stored at `index` is @@ -186,7 +186,8 @@ class IntegerNode : public NumberNode { IntegerNode() : IntegerNode({}) {} // Create an integer array with the user-defined index- and axis-wise bounds. - // Index-wise bounds default to the specified default bounds. + // Index-wise bounds default to the specified default bounds. By default, + // there are no axis-wise bounds. IntegerNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, @@ -253,7 +254,8 @@ class BinaryNode : public IntegerNode { BinaryNode() : BinaryNode({}) {} // Create a binary array with the user-defined index- and axis-wise bounds. - // Index-wise bounds default to lower_bound = 0.0 and upper_bound = 1.0. + // Index-wise bounds default to lower_bound = 0.0 and upper_bound = 1.0. By + // default, there are no axis-wise bounds. BinaryNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index fa7bd38f..77e43225 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -64,7 +64,9 @@ NumberNode::BoundAxisOperator NumberNode::BoundAxisInfo::get_operator(const ssiz /// State dependant data attached to NumberNode struct NumberNodeStateData : public ArrayNodeStateData { + // User does not provide axis-wise bounds. NumberNodeStateData(std::vector input) : ArrayNodeStateData(std::move(input)) {} + // User provides axis-wise bounds. NumberNodeStateData(std::vector input, std::vector> bound_axes_sums) : ArrayNodeStateData(std::move(input)), bound_axes_sums(std::move(bound_axes_sums)), @@ -73,6 +75,7 @@ struct NumberNodeStateData : public ArrayNodeStateData { /// track the sum of the values within the hyperslice. /// bound_axes_sums[i][j] = "sum of the values within the jth /// hyperslice along the ith bound axis" + /// Note that "ith bound axis" does not necessarily mean the ith axis. std::vector> bound_axes_sums; // Store a copy for NumberNode::revert() and commit() std::vector> prior_bound_axes_sums; @@ -104,11 +107,13 @@ std::vector> get_bound_axes_sums(const NumberNode* node, // For each bound axis, initialize the sum of the values contained in each // of it's hyperslice to 0. Define bound_axes_sums[i][j] = "sum of the - // values within the jth hyperslice along the ith bound axis" + // values within the jth hyperslice along the ith bound axis". std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); for (const NumberNode::BoundAxisInfo& axis_info : bound_axes_info) { assert(0 <= axis_info.axis && axis_info.axis < static_cast(node_shape.size())); + // Emplace an all zeros vector of size equal to the number of hyperslice + // along the given bound axis (axis_info.axis). bound_axes_sums.emplace_back(node_shape[axis_info.axis], 0.0); } @@ -116,7 +121,7 @@ std::vector> get_bound_axes_sums(const NumberNode* node, // NumberNode and iterate over it. for (BufferIterator it(number_data.data(), node_shape, node->strides()); it != std::default_sentinel; ++it) { - // Increment the appropriate hyperslice along each bound axis. + // Increment the sum of the appropriate hyperslice along each bound axis. for (ssize_t bound_axis = 0; bound_axis < num_bound_axes; ++bound_axis) { const ssize_t axis = bound_axes_info[bound_axis].axis; assert(0 <= axis && axis < static_cast(it.location().size())); @@ -134,11 +139,12 @@ std::vector> get_bound_axes_sums(const NumberNode* node, bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, const std::vector>& bound_axes_sums) { assert(bound_axes_info.size() == bound_axes_sums.size()); - // Check that each hyperslice satisfies the axis-wise bounds. + // Iterate over each bound axis for (ssize_t i = 0, stop_i = static_cast(bound_axes_info.size()); i < stop_i; ++i) { const auto& bound_axis_info = bound_axes_info[i]; const auto& bound_axis_sums = bound_axes_sums[i]; + // Return `false` if any slice does not satisfy the axis-wise bounds. for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); slice < stop_slice; ++slice) { switch (bound_axis_info.get_operator(slice)) { @@ -175,7 +181,7 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat return; } - // Given the assingnment to NumberNode, `number_data`, get the sum of the + // Given the assingnment to NumberNode `number_data`, compute the sum of the // values within each hyperslice along each bound axis. std::vector> bound_axes_sums = get_bound_axes_sums(this, number_data); @@ -187,7 +193,7 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat std::move(bound_axes_sums)); } -/// Given a `span` (typically containing strides or shape), reorder the values +/// Given a `span` (used for strides or shape data), reorder the values /// of the span such that the given `axis` is moved to the 0th index. std::vector shift_axis_data(const std::span span, const ssize_t axis) { const ssize_t ndim = span.size(); @@ -201,7 +207,7 @@ std::vector shift_axis_data(const std::span span, const return output; } -/// Undo the operation defined by `shift_axis_data()`. +/// Reverse the operation defined by `shift_axis_data()`. std::vector undo_shift_axis_data(const std::span span, const ssize_t axis) { const ssize_t ndim = span.size(); std::vector output; @@ -219,7 +225,7 @@ std::vector undo_shift_axis_data(const std::span span, c /// Given a `slice` along a bound axis in a NumberNode where the sum of it's /// values are given by `sum`, determine the non-negative amount `delta` -/// needed to be added to `sum` to satisfy the expression: (sum+delta) op bound +/// needed to be added to `sum` to satisfy the expression: `(sum+delta) op bound` /// e.g. Given (sum, op, bound) := (10, ==, 12), delta = 2 /// e.g. Given (sum, op, bound) := (10, <=, 12), delta = 0 /// e.g. Given (sum, op, bound) := (10, >=, 12), delta = 2 @@ -244,8 +250,8 @@ double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, } } -/// Given a NumberNod and exactly one axis-wise bound defined for NumberNode, -/// assign values to `values` (in-place) to satisfy the axis-wise bound. This method +/// Given a NumberNode and exactly one axis-wise bound, assign values to +/// `values` (in-place) to satisfy the axis-wise bound. This method /// A) Initially sets `values[i] = lower_bound(i)` for all i. /// B) Incremements the values within each hyperslice until they satisfy /// the axis-wise bound (should this be possible). @@ -258,10 +264,11 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, for (ssize_t i = 0, stop = node->size(); i < stop; ++i) { values.push_back(node->lower_bound(i)); } - // 2) Determine the hyperslice sums for the bound axis. This could be - // done during the previous loop if we want to improve performance. + // 2) Determine the hyperslice sums for the bound axis. To improve + // performance, compute sum during previous loop. assert(node->axis_wise_bounds().size() == 1); const std::vector bound_axis_sums = get_bound_axes_sums(node, values).front(); + // Obtain the stateless bound axis data for node. const NumberNode::BoundAxisInfo& bound_axis_info = node->axis_wise_bounds().front(); const ssize_t bound_axis = bound_axis_info.axis; assert(0 <= bound_axis && bound_axis < ndim); @@ -269,7 +276,7 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, // We need a way to iterate over each hyperslice along the bound axis and // adjust it`s values until they satisfy the axis-wise bounds. We do this // by defining an iterator of `values` that traverses each hyperslice one - // after another. This is equivalent to adjusting NumberNode shape and + // after another. This is equivalent to adjusting the node's shape and // strides such that the data for the bound_axis is moved to position 0. const std::vector buff_shape = shift_axis_data(node_shape, bound_axis); const std::vector buff_strides = shift_axis_data(node->strides(), bound_axis); @@ -284,33 +291,32 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, // 3) Iterate over each hyperslice and adjust it's values until they // satisfy the axis-wise bounds. for (ssize_t slice = 0, stop = node_shape[bound_axis]; slice < stop; ++slice) { - // Determine the amount we need to adjust the initialized values within - // the slice. + // Determine the amount needed to adjust the values within the slice. double delta = compute_bound_axis_slice_delta(slice, bound_axis_sums[slice], bound_axis_info.get_operator(slice), bound_axis_info.get_bound(slice)); if (delta == 0) continue; // Axis-wise bounds are satisfied for slice. assert(delta >= 0); // Should only increment. - // Determine how much we need to offset slice_0_it to get to the first - // index in the given `slice` + // Determine how much we need to offset `slice_0_it` to get to the first + // index in the given `slice`. const ssize_t offset = slice * slice_size; // Iterate over all indices in the given slice. - for (auto slice_begin_it = slice_0_it + offset, slice_end_it = slice_begin_it + slice_size; - slice_begin_it != slice_end_it; ++slice_begin_it) { - assert(slice_begin_it.location()[0] == slice); // We should be in the right slice. - // Determine the "true" index of `slice_it` given the node shape - ssize_t index = ravel_multi_index( - undo_shift_axis_data(slice_begin_it.location(), bound_axis), node_shape); - assert(0 <= index && index < static_cast(values.size())); + for (auto slice_it = slice_0_it + offset, slice_end_it = slice_it + slice_size; + slice_it != slice_end_it; ++slice_it) { + assert(slice_it.location()[0] == slice); // We should be in the right slice. + // Determine the "true" index of `slice_it` given the node shape. + ssize_t index = ravel_multi_index(undo_shift_axis_data(slice_it.location(), bound_axis), + node_shape); // Sanity check that we can correctly reverse the conversion. assert(std::ranges::equal(shift_axis_data(unravel_index(index, node_shape), bound_axis), - slice_begin_it.location())); - // Determine the amount we can increment the value in the given index. - const double inc = std::min(delta, node->upper_bound(index) - *slice_begin_it); + slice_it.location())); + assert(0 <= index && index < static_cast(values.size())); + // Determine allowable amount we can increment the value in at `index`. + const double inc = std::min(delta, node->upper_bound(index) - *slice_it); if (inc > 0) { // Apply the increment to both `it` and `delta`. - *slice_begin_it += inc; + *slice_it += inc; delta -= inc; if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. } @@ -324,7 +330,8 @@ void NumberNode::initialize_state(State& state) const { std::vector values; values.reserve(this->size()); - if (bound_axes_info_.size() == 0) { // No bound axes to consider + if (bound_axes_info_.size() == 0) { + // No bound axes to consider, initialize by default. for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { values.push_back(default_value(i)); } @@ -363,8 +370,7 @@ void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { // assert() that i and j are valid indices occurs in ptr->exchange(). // State change occurs IFF (i != j) and (buffer[i] != buffer[j]). if (ptr->exchange(i, j)) { - // If the values at indices i and j were exchanged, update the bound - // axis sums. + // If exchange occured, update the bound axis sums. const double difference = ptr->get(i) - ptr->get(j); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) update_bound_axis_slice_sums(state, i, difference); @@ -382,6 +388,7 @@ double NumberNode::lower_bound(ssize_t index) const { if (lower_bounds_.size() == 1) { return lower_bounds_[0]; } + assert(lower_bounds_.size() > 1); assert(0 <= index && index < static_cast(lower_bounds_.size())); return lower_bounds_[index]; } @@ -398,6 +405,7 @@ double NumberNode::upper_bound(ssize_t index) const { if (upper_bounds_.size() == 1) { return upper_bounds_[0]; } + assert(upper_bounds_.size() > 1); assert(0 <= index && index < static_cast(upper_bounds_.size())); return upper_bounds_[index]; } @@ -416,6 +424,7 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c // assert() that i is a valid index occurs in ptr->set(). // State change occurs IFF `value` != buffer[index] . if (ptr->set(index, value)) { + // If change occured, update bound axis sums by differnce. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } @@ -469,13 +478,12 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& } } -/// Check the user defined axis-wise bounds for NumberNode +/// Check the user defined axis-wise bounds for NumberNode. void check_axis_wise_bounds(const NumberNode* node) { const std::vector& bound_axes_info = node->axis_wise_bounds(); if (bound_axes_info.size() == 0) return; // No bound axes to check. const std::span shape = node->shape(); - // Used to asses if an axis have been bound multiple times. std::vector axis_bound(shape.size(), false); @@ -487,14 +495,12 @@ void check_axis_wise_bounds(const NumberNode* node) { throw std::invalid_argument("Invalid bound axis given number array shape."); } - // The number of operators defined for the given bound axis const ssize_t num_operators = static_cast(bound_axis_info.operators.size()); if ((num_operators > 1) && (num_operators != shape[axis])) { throw std::invalid_argument( "Invalid number of axis-wise operators given number array shape."); } - // The number of operators defined for the given bound axis const ssize_t num_bounds = static_cast(bound_axis_info.bounds.size()); if ((num_bounds > 1) && (num_bounds != shape[axis])) { throw std::invalid_argument( @@ -516,8 +522,8 @@ void check_axis_wise_bounds(const NumberNode* node) { throw std::invalid_argument("Axis-wise bounds are supported for at most one axis."); } - // There are quicker ways to check whether the axis-wise bounds are feasible. - // For now, we simply check whether we can construct a valid state. + // There are fasters ways to check whether the axis-wise bounds are feasible. + // For now, fully attempt to construct a state and throw if impossible. std::vector values; values.reserve(node->size()); construct_state_given_exactly_one_bound_axis(node, values); @@ -531,8 +537,7 @@ NumberNode::NumberNode(std::span shape, std::vector lower max_(get_extreme_index_wise_bound(upper_bound)), lower_bounds_(std::move(lower_bound)), upper_bounds_(std::move(upper_bound)), - bound_axes_info_(bound_axes.size() > 0 ? std::move(bound_axes) - : std::vector{}) { + bound_axes_info_(std::move(bound_axes)) { if ((shape.size() > 0) && (shape[0] < 0)) { throw std::invalid_argument("Number array cannot have dynamic size."); } @@ -558,14 +563,15 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, auto& bound_axes_sums = data_ptr(state)->bound_axes_sums; assert(bound_axes_info.size() == bound_axes_sums.size()); + // For each bound axis for (ssize_t bound_axis = 0, stop = static_cast(bound_axes_info.size()); bound_axis < stop; ++bound_axis) { assert(0 <= bound_axes_info[bound_axis].axis); assert(bound_axes_info[bound_axis].axis < static_cast(multi_index.size())); - // Get the slice along the bound axis the `value_change` occurs in + // Get the slice along the bound axis the `value_change` occurs in. const ssize_t slice = multi_index[bound_axes_info[bound_axis].axis]; assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); - // Offset running sum in slice + // Offset sum in slice. bound_axes_sums[bound_axis][slice] += value_change; } } @@ -599,8 +605,6 @@ IntegerNode::IntegerNode(std::span shape, if (min_ < minimum_lower_bound || max_ > maximum_upper_bound) { throw std::invalid_argument("range provided for integers exceeds supported range"); } - - check_bound_axes_integrality(bound_axes_info_); } IntegerNode::IntegerNode(std::initializer_list shape, @@ -675,6 +679,7 @@ void IntegerNode::set_value(State& state, ssize_t index, double value) const { // assert() that i is a valid index occurs in ptr->set(). // State change occurs IFF `value` != buffer[index]. if (ptr->set(index, value)) { + // If change occured, update bound axis sums by differnce. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } @@ -719,7 +724,7 @@ BinaryNode::BinaryNode(std::span shape, std::optional> upper_bound, std::vector bound_axes) : IntegerNode(shape, limit_bound_to_bool_domain(lower_bound), - limit_bound_to_bool_domain(upper_bound), bound_axes) {} + limit_bound_to_bool_domain(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index b4dcbe79..95a032eb 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -34,30 +34,36 @@ using NumberNode::LessEqual; TEST_CASE("BoundAxisInfo") { GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { - std::vector operators; - std::vector bounds{1.0}; - REQUIRE_THROWS_WITH(BoundAxisInfo(0, operators, bounds), + REQUIRE_THROWS_WITH(BoundAxisInfo(0, {}, {1.0}), "Axis-wise `operators` and `bounds` must have non-zero size."); } GIVEN("BoundAxisInfo(axis = 0, operators = {<=}, bounds = {})") { - std::vector operators{LessEqual}; - std::vector bounds; - REQUIRE_THROWS_WITH(BoundAxisInfo(0, operators, bounds), + REQUIRE_THROWS_WITH(BoundAxisInfo(0, {LessEqual}, {}), "Axis-wise `operators` and `bounds` must have non-zero size."); } GIVEN("BoundAxisInfo(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { - std::vector operators{LessEqual, Equal, Equal}; - std::vector bounds{2.0, 1.0}; REQUIRE_THROWS_WITH( - BoundAxisInfo(1, operators, bounds), + BoundAxisInfo(1, {LessEqual, Equal, Equal}, {2.0, 1.0}), "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); } - GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0})") { - std::vector operators{Equal}; + GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{1.0}; + BoundAxisInfo bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0}); + + THEN("The bound axis info is correct") { + CHECK(bound_axis.axis == 2); + CHECK_THAT(bound_axis.operators, RangeEquals(operators)); + CHECK_THAT(bound_axis.bounds, RangeEquals(bounds)); + } + } + + GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0, 2.0, 3.0})") { + std::vector operators{Equal}; + std::vector bounds{1.0, 2.0, 3.0}; BoundAxisInfo bound_axis(2, operators, bounds); THEN("The bound axis info is correct") { @@ -496,9 +502,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { - std::vector operators{Equal}; - std::vector bounds{1.0}; - std::vector bound_axes{{-1, operators, bounds}}; + std::vector bound_axes{{-1, {Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -506,9 +510,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { - std::vector operators{Equal}; - std::vector bounds{1.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, {Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -516,9 +518,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many operators.") { - std::vector operators{LessEqual, Equal, Equal, Equal}; - std::vector bounds{1.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual, Equal, Equal, Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -526,9 +526,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector operators{LessEqual, Equal}; - std::vector bounds{1.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual, Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -536,9 +534,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many bounds.") { - std::vector operators{Equal}; - std::vector bounds{1.0, 2.0, 3.0, 4.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {Equal}, {1.0, 2.0, 3.0, 4.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -546,9 +542,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector operators{LessEqual}; - std::vector bounds{1.0, 2.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual}, {1.0, 2.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -556,34 +550,26 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { - std::vector operators{Equal}; - std::vector bounds{1.0}; - BoundAxisInfo bound_axis{1, operators, bounds}; + BoundAxisInfo bound_axis{1, {Equal}, {1.0}}; + std::vector bound_axes{bound_axis, bound_axis}; - REQUIRE_THROWS_WITH( - graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, - std::nullopt, - std::vector{bound_axis, bound_axis}), - "Cannot define multiple axis-wise bounds for a single axis."); + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { - std::vector operators{LessEqual}; - std::vector bounds{1.0}; - BoundAxisInfo bound_axis_0{0, operators, bounds}; - BoundAxisInfo bound_axis_1{1, operators, bounds}; + BoundAxisInfo bound_axis_0{0, {LessEqual}, {1.0}}; + BoundAxisInfo bound_axis_1{1, {LessEqual}, {1.0}}; + std::vector bound_axes{bound_axis_0, bound_axis_1}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3}, std::nullopt, std::nullopt, - std::vector{bound_axis_0, bound_axis_1}), - "Axis-wise bounds are supported for at most one axis."); + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes), + "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-BinaryNode with non-integral axis-wise bounds") { - std::vector operators{Equal}; - std::vector bounds{0.1}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {Equal}, {0.1}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -592,9 +578,8 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{Equal, LessEqual, GreaterEqual}; - std::vector bounds{5.0, 2.0, 3.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{ + {0, {Equal, LessEqual, GreaterEqual}, {5.0, 2.0, 3.0}}}; // Each hyperslice along axis 0 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 0) that results in a // sum equal to 5. @@ -605,9 +590,7 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{Equal, GreaterEqual}; - std::vector bounds{5.0, 7.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {Equal, GreaterEqual}, {5.0, 7.0}}}; // Each hyperslice along axis 1 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 1) that results in a // sum greater than or equal to 7. @@ -618,9 +601,7 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector operators{Equal, LessEqual}; - std::vector bounds{5.0, -1.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, {Equal, LessEqual}, {5.0, -1.0}}}; // Each hyperslice along axis 2 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 2) that results in a // sum less than or equal to -1. @@ -633,9 +614,8 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0}; std::vector upper_bounds{0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1}; - std::vector operators{Equal, LessEqual, GreaterEqual}; - std::vector bounds{1.0, 2.0, 3.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{ + {0, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); @@ -653,18 +633,18 @@ TEST_CASE("BinaryNode") { // import numpy as np // a = np.asarray([i for i in range(3*2*2)]).reshape(3, 2, 2) // print(a[0, :, :].flatten()) - // ... [0 1 2 3] + // >>> [0 1 2 3] // print(a[1, :, :].flatten()) - // ... [4 5 6 7] + // >>> [4 5 6 7] // print(a[2, :, :].flatten()) - // ... [ 8 9 10 11] - std::vector expected_init{0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1}; + // >>> [ 8 9 10 11] + // // Cannonically least state that satisfies the index- and axis-wise // bounds // slice 0 slice 1 slice 2 // 0, 0 0, 0 1, 1 // 1, 0 0, 0 0, 1 - + std::vector expected_init{0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1}; auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); THEN("The bound axis sums and state are correct") { @@ -680,9 +660,7 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; std::vector upper_bounds{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; - std::vector operators{LessEqual, GreaterEqual}; - std::vector bounds{1.0, 5.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual, GreaterEqual}, {1.0, 5.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); @@ -700,15 +678,17 @@ TEST_CASE("BinaryNode") { // import numpy as np // a = np.asarray([i for i in range(3*2*2)]).reshape(3, 2, 2) // print(a[:, 0, :].flatten()) - // ... [0 1 4 5 8 9] + // >>> [0 1 4 5 8 9] // print(a[:, 1, :].flatten()) - // ... [ 2 3 6 7 10 11] - std::vector expected_init{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; + // >>> [ 2 3 6 7 10 11] + // // Cannonically least state that satisfies bounds // slice 0 slice 1 // 0, 0 1, 1 // 0, 0 1, 1 // 0, 0 0, 1 + std::vector expected_init{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; + auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); THEN("The bound axis sums and state are correct") { @@ -724,9 +704,7 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}; std::vector upper_bounds{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; - std::vector operators{Equal, GreaterEqual}; - std::vector bounds{3.0, 6.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, {Equal, GreaterEqual}, {3.0, 6.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); @@ -744,17 +722,17 @@ TEST_CASE("BinaryNode") { // import numpy as np // a = np.asarray([i for i in range(3*2*2)]).reshape(3, 2, 2) // print(a[:, :, 0].flatten()) - // ... [ 0 2 4 6 8 10] + // >>> [ 0 2 4 6 8 10] // print(a[:, :, 1].flatten()) - // ... [ 1 3 5 7 9 11] - std::vector expected_init{0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1}; + // >>> [ 1 3 5 7 9 11] + // // Cannonically least state that satisfies the index- and axis-wise // bounds // slice 0 slice 1 // 0, 1 1, 1 // 1, 0 1, 1 // 0, 1 1, 1 - + std::vector expected_init{0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1}; auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); THEN("The bound axis sums and state are correct") { @@ -768,9 +746,8 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{Equal, LessEqual, GreaterEqual}; - std::vector bounds{1.0, 2.0, 3.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{ + {0, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, bound_axes); @@ -782,6 +759,13 @@ TEST_CASE("BinaryNode") { CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); } + WHEN("We create a state using a random number generator") { + auto state = graph.empty_state(); + auto rng = std::default_random_engine(42); + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, rng), + "Cannot randomly initialize_state with bound axes."); + } + WHEN("We initialize three invalid states") { auto state = graph.empty_state(); // This state violates the 0th hyperslice along axis 0 @@ -831,6 +815,7 @@ TEST_CASE("BinaryNode") { // a = np.asarray([0, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) // a = a.reshape(3, 2, 2) // a.sum(axis=(1, 2)) + // >>> array([1, 2, 4]) CHECK(bnode_ptr->bound_axis_sums(state).size() == 1); CHECK(bnode_ptr->bound_axis_sums(state).data()[0].size() == 3); CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); @@ -851,6 +836,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(1, a.shape)] = 0 // a[np.unravel_index(3, a.shape)] = 1 // a.sum(axis=(1, 2)) + // >>> array([1, 2, 4]) CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 2); // 2 updates per exchange CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); @@ -888,6 +874,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(11, a.shape)] = 1 // a[np.unravel_index(10, a.shape)] = 0 // a.sum(axis=(1, 2)) + // >>> array([1, 1, 3]) CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 4); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); @@ -927,6 +914,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(10, a.shape)] = 1 // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(1, 2)) + // >>> array([1, 1, 3]) CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 4); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); @@ -957,6 +945,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(4, a.shape)] = 1 // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(1, 2)) + // >>> array([1, 2, 3]) CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 3})); CHECK(bnode_ptr->diff(state).size() == 3); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); @@ -987,6 +976,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(6, a.shape)] = 0 // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(1, 2)) + // >>> array([1, 1, 3]) CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 2); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); @@ -1320,9 +1310,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { - std::vector operators{Equal}; - std::vector bounds{20.0}; - std::vector bound_axes{{-2, operators, bounds}}; + std::vector bound_axes{{-2, {Equal}, {20.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -1330,9 +1318,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { - std::vector operators{Equal}; - std::vector bounds{10.0}; - std::vector bound_axes{{3, operators, bounds}}; + std::vector bound_axes{{3, {Equal}, {10.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1340,9 +1326,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many operators.") { - std::vector operators{LessEqual, Equal, Equal, Equal}; - std::vector bounds{-10.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual, Equal, Equal, Equal}, {-10.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1350,9 +1334,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector operators{LessEqual, Equal}; - std::vector bounds{-11.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual, Equal}, {-11.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1360,9 +1342,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many bounds.") { - std::vector operators{LessEqual}; - std::vector bounds{-10.0, 20.0, 30.0, 40.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual}, {-10.0, 20.0, 30.0, 40.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1370,9 +1350,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector operators{LessEqual}; - std::vector bounds{111.0, -223.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual}, {111.0, -223.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1380,34 +1358,23 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { - std::vector operators{Equal}; - std::vector bounds{100.0}; - BoundAxisInfo bound_axis{1, operators, bounds}; + std::vector bound_axes{{1, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; - REQUIRE_THROWS_WITH( - graph.emplace_node(std::initializer_list{2, 3, 4}, - std::nullopt, std::nullopt, - std::vector{bound_axis, bound_axis}), - "Cannot define multiple axis-wise bounds for a single axis."); + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Cannot define multiple axis-wise bounds for a single axis."); } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { - std::vector operators{Equal}; - std::vector bounds{100.0}; - BoundAxisInfo bound_axis_0{0, operators, bounds}; - BoundAxisInfo bound_axis_1{1, operators, bounds}; + std::vector bound_axes{{0, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; - REQUIRE_THROWS_WITH( - graph.emplace_node( - std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, - std::vector{bound_axis_0, bound_axis_1}), - "Axis-wise bounds are supported for at most one axis."); + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, bound_axes), + "Axis-wise bounds are supported for at most one axis."); } GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - std::vector operators{LessEqual}; - std::vector bounds{11.0, 12.0001, 0.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {LessEqual}, {11.0, 12.0001, 0.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1416,12 +1383,10 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{Equal, LessEqual}; - std::vector bounds{5.0, -31.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, {Equal, LessEqual}, {5.0, -31.0}}}; // Each hyperslice along axis 0 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 0) that results in a - // sum less than or equal to -5*6-1 = -31. + // sum less than or equal to -5*6 - 1 = -31. REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes), "Infeasible axis-wise bounds."); @@ -1429,12 +1394,10 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{GreaterEqual, Equal, Equal}; - std::vector bounds{33.0, 0.0, 0.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{{1, {GreaterEqual, Equal, Equal}, {33.0, 0.0, 0.0}}}; // Each hyperslice along axis 1 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 1) that results in a - // sum greater than or equal to 4*8+1 = 33. + // sum greater than or equal to 4*8 + 1 = 33. REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes), "Infeasible axis-wise bounds."); @@ -1442,12 +1405,10 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector operators{GreaterEqual, Equal}; - std::vector bounds{-1.0, 49.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, {GreaterEqual, Equal}, {-1.0, 49.0}}}; // Each hyperslice along axis 2 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 2) that results in a - // sum or equal to 6*8+1 = 49 + // sum or equal to 6*8 + 1 = 49 REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes), "Infeasible axis-wise bounds."); @@ -1455,9 +1416,7 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector operators{Equal, GreaterEqual}; - std::vector bounds{-21.0, 9.0}; - std::vector bound_axes{{0, operators, bounds}}; + std::vector bound_axes{{0, {Equal, GreaterEqual}, {-21.0, 9.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); @@ -1475,11 +1434,12 @@ TEST_CASE("IntegerNode") { // import numpy as np // a = np.asarray([i for i in range(2*3*2)]).reshape(2, 3, 2) // print(a[0, :, :].flatten()) - // ... [0 1 2 3 4 5] + // >>> [0 1 2 3 4 5] // print(a[1, :, :].flatten()) - // ... [ 6 7 8 9 10 11] + // >>> [ 6 7 8 9 10 11] // - // initialize_state() will start with + // The method `construct_state_given_exactly_one_bound_axis()` + // will construct a state as follows: // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] // repair slice 0 // [4, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] @@ -1499,9 +1459,8 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{Equal, GreaterEqual, LessEqual}; - std::vector bounds{0.0, -2.0, 0.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{ + {1, {Equal, GreaterEqual, LessEqual}, {0.0, -2.0, 0.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); @@ -1519,13 +1478,14 @@ TEST_CASE("IntegerNode") { // import numpy as np // a = np.asarray([i for i in range(2*3*2)]).reshape(2, 3, 2) // print(a[:, 0, :].flatten()) - // ... [0 1 6 7] + // >>> [0 1 6 7] // print(a[:, 1, :].flatten()) - // ... [2 3 8 9] + // >>> [2 3 8 9] // print(a[:, 2, :].flatten()) - // ... [ 4 5 10 11] + // >>> [ 4 5 10 11] // - // initialize_state() will start with + // The method `construct_state_given_exactly_one_bound_axis()` + // will construct a state as follows: // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] // repair slice 0 w/ [8, 2, -5, -5] // [8, 2, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] @@ -1546,9 +1506,7 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector operators{Equal, GreaterEqual}; - std::vector bounds{23.0, 14.0}; - std::vector bound_axes{{2, operators, bounds}}; + std::vector bound_axes{{2, {Equal, GreaterEqual}, {23.0, 14.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); @@ -1566,11 +1524,12 @@ TEST_CASE("IntegerNode") { // import numpy as np // a = np.asarray([i for i in range(2*3*2)]).reshape(2, 3, 2) // print(a[:, :, 0].flatten()) - // ... [ 0 2 4 6 8 10] - // print(a[:, :, 0].flatten()) - // ... [ 1 3 5 7 9 11] + // >>> [ 0 2 4 6 8 10] + // print(a[:, :, 1].flatten()) + // >>> [ 1 3 5 7 9 11] // - // initialize_state() will start with + // The method `construct_state_given_exactly_one_bound_axis()` + // will construct a state as follows: // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] // repair slice 0 w/ [8, 8, 8, 8, -4, -5] // [8, -5, 8, -5, 8, -5, 8, -5, -4, -5, -5, -5] @@ -1590,9 +1549,8 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector operators{Equal, LessEqual, GreaterEqual}; - std::vector bounds{11.0, 2.0, 5.0}; - std::vector bound_axes{{1, operators, bounds}}; + std::vector bound_axes{ + {1, {Equal, LessEqual, GreaterEqual}, {11.0, 2.0, 5.0}}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); @@ -1604,6 +1562,13 @@ TEST_CASE("IntegerNode") { CHECK_THAT(bound_axes[0].bounds, RangeEquals(inode_bound_axis_ptr.bounds)); } + WHEN("We create a state using a random number generator") { + auto state = graph.empty_state(); + auto rng = std::default_random_engine(42); + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, rng), + "Cannot randomly initialize_state with bound axes."); + } + WHEN("We initialize three invalid states") { auto state = graph.empty_state(); // This state violates the 0th hyperslice along axis 1 @@ -1678,6 +1643,7 @@ TEST_CASE("IntegerNode") { // a[np.unravel_index(0, a.shape)] = 2 // a[np.unravel_index(1, a.shape)] = 5 // a.sum(axis=(0, 2)) + // >>> array([11, 0, 9]) CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 0, 9})); CHECK(inode_ptr->diff(state).size() == 4); // 2 updates per exchange CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); @@ -1706,6 +1672,7 @@ TEST_CASE("IntegerNode") { // a[np.unravel_index(8, a.shape)] = -5 // a[np.unravel_index(10, a.shape)] = 8 // a.sum(axis=(0, 2)) + // >>> array([11, -5, 15]) CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, -5, 15})); CHECK(inode_ptr->diff(state).size() == 2); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); @@ -1742,6 +1709,7 @@ TEST_CASE("IntegerNode") { // a[np.unravel_index(10, a.shape)] = 5 // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(0, 2)) + // >>> array([11, 1, 9]) CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, 1, 9})); CHECK(inode_ptr->diff(state).size() == 4); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); From 23834b619c6eb260d7eb6378ea4b3f009f1210dc Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 11:52:22 -0800 Subject: [PATCH 20/22] Cleaned up Python and Cython code for NumberNode Specific to axis-wise bounds. --- dwave/optimization/libcpp/nodes/numbers.pxd | 4 +- dwave/optimization/model.py | 79 +++++++++---------- dwave/optimization/symbols/numbers.pyx | 21 +++-- ...ode_axis_wise_bounds-594110e581c1115f.yaml | 2 +- 4 files changed, 55 insertions(+), 51 deletions(-) diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index f5b6e0b9..dfccccbe 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -22,8 +22,8 @@ cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimi cdef cppclass NumberNode(ArrayNode): enum BoundAxisOperator : - # It appears Cython automatically assumes all (standard) enums are "public" - # hence we override here. + # It appears Cython automatically assumes all (standard) enums are "public". + # Because of this, these very explict overrides are needed per enum item. Equal "dwave::optimization::NumberNode::BoundAxisOperator::Equal" LessEqual "dwave::optimization::NumberNode::BoundAxisOperator::LessEqual" GreaterEqual "dwave::optimization::NumberNode::BoundAxisOperator::GreaterEqual" diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index d33822de..d723d25f 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -179,17 +179,17 @@ def binary(self, shape: None | _ShapeLike = None, scalar (one bound for all variables) or an array (one bound for each variable). Non-boolean values are rounded down to the domain [0,1]. If None, the default value of 1 is used. - subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples. Each tuple is of the form: (axis, operator(s), - bound(s)) where `axis` (int) is the axis to apply the bound(s), - `operator(s)` (str | array[str]) is the operator(s) ("<=", - "==", or ">=") defined for all hyperslice or per hyperslice - along the bound axis, and `bound(s)` (float | array[float]) is - the bound(s) defined for all hyperslice or per hyperslice - hyperslice along the bound axis. If provided, the sum of the - values within each hyperslice along each bound axis will - satisfy the axis-wise bounds. Note: At most one axis-wise bound - may be provided. + subject_to (optional): Axis-wise bounds applied to the symbol. Must be an + array of tuples where each tuple has the form: (axis, operators, bounds) + - axis (int): The axis along which the bounds are applied. + - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). + A single operator applies to all hyperslices along the axis; an + array specifies one operator per hyperslice. + - bounds (float | array[float]): The bound value(s). A single value + applies to all hyperslices; an array specifies one bound per hyperslice. + If provided, the sum of values within each hyperslice along the specified + axis must satisfy the corresponding operator–bound pair. + Note: At most one axis-wise bound may be provided. Returns: A binary symbol. @@ -227,26 +227,19 @@ def binary(self, shape: None | _ShapeLike = None, >>> np.all([1, 0] == b.upper_bound()) True - This example adds a :math:`2`-sized binary symbol with a scalar lower - bound and index-wise upper bounds to a model. - - >>> from dwave.optimization.model import Model - >>> import numpy as np - >>> model = Model() - >>> b = model.binary(2, lower_bound=-1.1, upper_bound=[1.1, 0.9]) - >>> np.all([0, 0] == b.lower_bound()) - True - >>> np.all([1, 0] == b.upper_bound()) - True - - This example adds a :math:`(2x3)`-sized binary symbol with index-wise - lower bounds and an axis-wise bound along axis 1. + This example adds a :math:`(2x3)`-sized binary symbol with + index-wise lower bounds and an axis-wise bound along axis 1. Let + x_i (int i : 0 <= i <= 2) denote the sum of the values within + hyperslice i along axis 1. For each state defined for this symbol: + (x_0 <= 0), (x_1 == 2), and (x_2 >= 1). >>> from dwave.optimization.model import Model >>> import numpy as np >>> model = Model() - >>> i = model.binary([2,3], lower_bound=[[0, 1, 1], [0, 1, 0]], + >>> n = model.binary([2, 3], lower_bound=[[0, 1, 1], [0, 1, 0]], ... subject_to=[(1, ["<=", "==", ">="], [0, 2, 1])]) + >>> np.all(n.axis_wise_bounds() == [(1, ["<=", "==", ">="], [0, 2, 1])]) + True See Also: :class:`~dwave.optimization.symbols.numbers.BinaryVariable`: equivalent symbol. @@ -529,17 +522,17 @@ def integer( scalar (one bound for all variables) or an array (one bound for each variable). Non-integer values are down up. If None, the default value is used. - subject_to (optional): Axis-wise bounds for the symbol. Must be an - array of tuples. Each tuple is of the form: (axis, operator(s), - bound(s)) where `axis` (int) is the axis to apply the bound(s), - `operator(s)` (str | array[str]) is the operator(s) ("<=", - "==", or ">=") defined for all hyperslice or per hyperslice - along the bound axis, and `bound(s)` (float | array[float]) is - the bound(s) defined for all hyperslice or per hyperslice - hyperslice along the bound axis. If provided, the sum of the - values within each hyperslice along each bound axis will - satisfy the axis-wise bounds. Note: At most one axis-wise bound - may be provided. + subject_to (optional): Axis-wise bounds applied to the symbol. Must be an + array of tuples where each tuple has the form: (axis, operators, bounds) + - axis (int): The axis along which the bounds are applied. + - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). + A single operator applies to all hyperslices along the axis; an + array specifies one operator per hyperslice. + - bounds (float | array[float]): The bound value(s). A single value + applies to all hyperslices; an array specifies one bound per hyperslice. + If provided, the sum of values within each hyperslice along the specified + axis must satisfy the corresponding operator–bound pair. + Note: At most one axis-wise bound may be provided. Returns: An integer symbol. @@ -578,15 +571,19 @@ def integer( >>> np.all([1, 2] == i.upper_bound()) True - This example adds a :math:`(2x3)`-sized integer symbol with - general lower and upper bounds and an axis-wise bound along - axis 1. + This example adds a :math:`(2x3)`-sized integer symbol with general + lower and upper bounds and an axis-wise bound along axis 1. Let x_i + (int i : 0 <= i <= 2) denote the sum of the values within + hyperslice i along axis 1. For each state defined for this symbol: + (x_0 <= 2), (x_1 <= 4), and (x_2 <= 5). >>> from dwave.optimization.model import Model >>> import numpy as np >>> model = Model() - >>> i = model.integer([2,3], lower_bound=1, upper_bound=3, + >>> i = model.integer([2, 3], lower_bound=1, upper_bound=3, ... subject_to=[(1, "<=", [2, 4, 5])]) + >>> np.all(i.axis_wise_bounds() == [(1, ["<="], [2, 4, 5])]) + True See Also: :class:`~dwave.optimization.symbols.numbers.IntegerVariable`: equivalent symbol. diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 54239828..1b3b6429 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -34,6 +34,8 @@ from dwave.optimization.libcpp.nodes.numbers cimport ( from dwave.optimization.states cimport States +# Convert the str operators "==", "<=", ">=" into their corresponding +# C++ objects. cdef NumberNode.BoundAxisOperator _parse_python_operator(str op) except *: if op == "==": return NumberNode.BoundAxisOperator.Equal @@ -45,6 +47,8 @@ cdef NumberNode.BoundAxisOperator _parse_python_operator(str op) except *: raise TypeError(f"Invalid bound axis operator: {op!r}") +# Convert the user-defined axis-wise bounds for NumberNode into the +# corresponding C++ objects passed to NumberNode. cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( bound_axes_data : None | list[tuple(int, str | list[str], float | list[float])]) except *: cdef vector[NumberNode.BoundAxisInfo] output @@ -59,7 +63,6 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( for bound_axis_data in bound_axes_data: if not isinstance(bound_axis_data, tuple) or len(bound_axis_data) != 3: - print(bound_axis_data) raise TypeError("Each bound axis entry must be a tuple with" " three elements: axis, operator(s), bound(s)") @@ -70,16 +73,20 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( cpp_ops.clear() if isinstance(py_ops, str): + # One operator defined for all slices. cpp_ops.push_back(_parse_python_operator(py_ops)) else: + # Operator defined per slice. ops_array = np.asarray(py_ops, order='C') if (ops_array.ndim <= 1): cpp_ops.reserve(ops_array.size) for op in ops_array: + # Convert op to `str` because _parse_python_operator() + # does not expect a `numpy.str_`. cpp_ops.push_back(_parse_python_operator(str(op))) else: raise TypeError("Bound axis operator(s) should be str or" - " 1D-array of str.") + " a 1D-array of str(s).") cpp_bounds.clear() bound_array = np.asarray_chkfinite(py_bounds, dtype=np.double, order='C') @@ -95,7 +102,7 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( return output - +# Convert the C++ operators into their corresponding str cdef str _parse_cpp_operators(NumberNode.BoundAxisOperator op): if op == NumberNode.BoundAxisOperator.Equal: return "==" @@ -200,8 +207,8 @@ cdef class BinaryVariable(ArraySymbol): else: with zf.open(info, "r") as f: subject_to = json.load(f) - # Note that import is a list of lists, not a list of tuples, - # hence we convert to tuple. We could also support lists. + # Note that import is a list of lists, not a list of tuples. + # Hence we convert to tuple. We could also support lists. subject_to = [(axis, ops, bounds) for axis, ops, bounds in subject_to] return BinaryVariable(model, @@ -410,8 +417,8 @@ cdef class IntegerVariable(ArraySymbol): with zf.open(info, "r") as f: # Note that import is a list of lists, not a list of tuples subject_to = json.load(f) - # Note that import is a list of lists, not a list of tuples, - # hence we convert to tuple. We could also support lists. + # Note that import is a list of lists, not a list of tuples. + # Hence we convert to tuple. We could also support lists. subject_to = [(axis, ops, bounds) for axis, ops, bounds in subject_to] return IntegerVariable(model, diff --git a/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml b/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml index 18239672..d547c98f 100644 --- a/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml +++ b/releasenotes/notes/numbernode_axis_wise_bounds-594110e581c1115f.yaml @@ -2,4 +2,4 @@ features: - | Axis-wise bounds added to NumberNode. Available to both IntegerNode and - BinaryNode. + BinaryNode. See #216` `_. From ffdae9a765798fa03a4d17331fbf1857eb0bfd9d Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 16:10:26 -0800 Subject: [PATCH 21/22] New names for NumberNode bound axis data `BoundAxisInfo` -> `AxisBound` and `BoundAxisOperator` -> `Operator`. `Operator` is now a nested enum classs of `AxisBound`. --- .../dwave-optimization/nodes/numbers.hpp | 74 +++++----- dwave/optimization/libcpp/nodes/numbers.pxd | 18 +-- dwave/optimization/src/nodes/numbers.cpp | 92 ++++++------ dwave/optimization/symbols/numbers.pyx | 32 ++--- tests/cpp/nodes/test_numbers.cpp | 135 +++++++++--------- 5 files changed, 173 insertions(+), 178 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index df938bcf..3d500bf7 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -28,23 +28,24 @@ namespace dwave::optimization { /// A contiguous block of numbers. class NumberNode : public ArrayOutputMixin, public DecisionNode { public: - /// Allowable axis-wise bound operators. - enum BoundAxisOperator { Equal, LessEqual, GreaterEqual }; - /// Struct for stateless axis-wise bound information. Given an `axis`, define /// constraints on the sum of the values in each slice along `axis`. /// Constraints can be defined for ALL slices along `axis` or PER slice along - /// `axis`. Allowable operators are defined by `BoundAxisOperator`. - struct BoundAxisInfo { + /// `axis`. Allowable operators are defined by `Operator`. + struct AxisBound { + /// Allowable axis-wise bound operators. + enum class Operator { Equal, LessEqual, GreaterEqual }; + /// To reduce the # of `IntegerNode` and `BinaryNode` constructors, we /// allow only one constructor. - BoundAxisInfo(ssize_t axis, std::vector axis_operators, - std::vector axis_bounds); + AxisBound(ssize_t axis, std::vector axis_operators, + std::vector axis_bounds); + /// The bound axis ssize_t axis; /// Operator for ALL axis slices (vector has length one) or operators PER /// slice (length of vector is equal to the number of slices). - std::vector operators; + std::vector operators; /// Bound for ALL axis slices (vector has length one) or bounds PER slice /// (length of vector is equal to the number of slices). std::vector bounds; @@ -53,7 +54,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { double get_bound(const ssize_t slice) const; /// Obtain the operator associated with a given slice along `axis`. - BoundAxisOperator get_operator(const ssize_t slice) const; + Operator get_operator(const ssize_t slice) const; }; NumberNode() = delete; @@ -140,7 +141,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { void clip_and_set_value(State& state, ssize_t index, double value) const; /// Return the stateless axis-wise bound information i.e. bound_axes_info_. - const std::vector& axis_wise_bounds() const; + const std::vector& axis_wise_bounds() const; /// Return the state-dependent sum of the values within each hyperslice /// along each bound axis. @@ -148,8 +149,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { protected: explicit NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound, - std::vector bound_axes = {}); + std::vector upper_bound, std::vector bound_axes = {}); // Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; @@ -171,7 +171,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::vector upper_bounds_; /// Stateless information on each bound axis. - std::vector bound_axes_info_; + std::vector bound_axes_info_; }; /// A contiguous block of integer numbers. @@ -191,39 +191,39 @@ class IntegerNode : public NumberNode { IntegerNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(std::span shape, std::optional> lower_bound, - double upper_bound, std::vector bound_axes = {}); + double upper_bound, std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); // Overloads needed by the Node ABC *************************************** @@ -259,38 +259,38 @@ class BinaryNode : public IntegerNode { BinaryNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(std::span shape, std::optional> lower_bound, - double upper_bound, std::vector bound_axes = {}); + double upper_bound, std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound, - double upper_bound, std::vector bound_axes = {}); + double upper_bound, std::vector bound_axes = {}); BinaryNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector bound_axes = {}); // Flip the value (0 -> 1 or 1 -> 0) at index i in the given state. void flip(State& state, ssize_t i) const; diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index dfccccbe..9bef9a52 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -21,18 +21,18 @@ from dwave.optimization.libcpp.state cimport State cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimization" nogil: cdef cppclass NumberNode(ArrayNode): - enum BoundAxisOperator : + struct AxisBound: # It appears Cython automatically assumes all (standard) enums are "public". - # Because of this, these very explict overrides are needed per enum item. - Equal "dwave::optimization::NumberNode::BoundAxisOperator::Equal" - LessEqual "dwave::optimization::NumberNode::BoundAxisOperator::LessEqual" - GreaterEqual "dwave::optimization::NumberNode::BoundAxisOperator::GreaterEqual" + # Because of this, we use this very explict override. + enum class Operator "dwave::optimization::NumberNode::AxisBound::Operator": + Equal + LessEqual + GreaterEqual - struct BoundAxisInfo: - BoundAxisInfo(Py_ssize_t axis, vector[BoundAxisOperator] axis_opertors, + AxisBound(Py_ssize_t axis, vector[Operator] axis_opertors, vector[double] axis_bounds) Py_ssize_t axis - vector[BoundAxisOperator] operators; + vector[Operator] operators; vector[double] bounds; void initialize_state(State&, vector[double]) except+ @@ -40,7 +40,7 @@ cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimi double upper_bound(Py_ssize_t index) double lower_bound() except+ double upper_bound() except+ - const vector[BoundAxisInfo] axis_wise_bounds() + const vector[AxisBound] axis_wise_bounds() cdef cppclass IntegerNode(NumberNode): pass diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 77e43225..3b7fada7 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -29,9 +29,8 @@ namespace dwave::optimization { -NumberNode::BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, - std::vector axis_operators, - std::vector axis_bounds) +NumberNode::AxisBound::AxisBound(ssize_t bound_axis, std::vector axis_operators, + std::vector axis_bounds) : axis(bound_axis), operators(std::move(axis_operators)), bounds(std::move(axis_bounds)) { const size_t num_operators = operators.size(); const size_t num_bounds = bounds.size(); @@ -48,14 +47,14 @@ NumberNode::BoundAxisInfo::BoundAxisInfo(ssize_t bound_axis, } } -double NumberNode::BoundAxisInfo::get_bound(const ssize_t slice) const { +double NumberNode::AxisBound::get_bound(const ssize_t slice) const { assert(0 <= slice); if (bounds.size() == 1) return bounds[0]; assert(slice < static_cast(bounds.size())); return bounds[slice]; } -NumberNode::BoundAxisOperator NumberNode::BoundAxisInfo::get_operator(const ssize_t slice) const { +NumberNode::AxisBound::Operator NumberNode::AxisBound::get_operator(const ssize_t slice) const { assert(0 <= slice); if (operators.size() == 1) return operators[0]; assert(slice < static_cast(operators.size())); @@ -110,7 +109,7 @@ std::vector> get_bound_axes_sums(const NumberNode* node, // values within the jth hyperslice along the ith bound axis". std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); - for (const NumberNode::BoundAxisInfo& axis_info : bound_axes_info) { + for (const NumberNode::AxisBound& axis_info : bound_axes_info) { assert(0 <= axis_info.axis && axis_info.axis < static_cast(node_shape.size())); // Emplace an all zeros vector of size equal to the number of hyperslice // along the given bound axis (axis_info.axis). @@ -136,7 +135,7 @@ std::vector> get_bound_axes_sums(const NumberNode* node, /// Determine whether the sum of the values within each hyperslice along /// each bound axis satisfies the axis-wise bounds. -bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, +bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, const std::vector>& bound_axes_sums) { assert(bound_axes_info.size() == bound_axes_sums.size()); // Iterate over each bound axis @@ -148,13 +147,13 @@ bool satisfies_axis_wise_bounds(const std::vector& bo for (ssize_t slice = 0, stop_slice = static_cast(bound_axis_sums.size()); slice < stop_slice; ++slice) { switch (bound_axis_info.get_operator(slice)) { - case NumberNode::Equal: + case NumberNode::AxisBound::Operator::Equal: if (bound_axis_sums[slice] != bound_axis_info.get_bound(slice)) return false; break; - case NumberNode::LessEqual: + case NumberNode::AxisBound::Operator::LessEqual: if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) return false; break; - case NumberNode::GreaterEqual: + case NumberNode::AxisBound::Operator::GreaterEqual: if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; break; default: @@ -231,17 +230,18 @@ std::vector undo_shift_axis_data(const std::span span, c /// e.g. Given (sum, op, bound) := (10, >=, 12), delta = 2 /// Throws an error if `delta` is negative (corresponding with an infeasible axis-wise bound); double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, - const NumberNode::BoundAxisOperator op, const double bound) { + const NumberNode::AxisBound::Operator op, + const double bound) { switch (op) { - case NumberNode::Equal: + case NumberNode::AxisBound::Operator::Equal: if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); // If error was not thrown, return amount needed to satisfy bound. return bound - sum; - case NumberNode::LessEqual: + case NumberNode::AxisBound::Operator::LessEqual: if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); // If error was not thrown, sum satisfies bound. return 0.0; - case NumberNode::GreaterEqual: + case NumberNode::AxisBound::Operator::GreaterEqual: // If sum is less than bound, return the amount needed to equal it. // Otherwise, sum satisfies bound. return (sum < bound) ? (bound - sum) : 0.0; @@ -269,7 +269,7 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, assert(node->axis_wise_bounds().size() == 1); const std::vector bound_axis_sums = get_bound_axes_sums(node, values).front(); // Obtain the stateless bound axis data for node. - const NumberNode::BoundAxisInfo& bound_axis_info = node->axis_wise_bounds().front(); + const NumberNode::AxisBound& bound_axis_info = node->axis_wise_bounds().front(); const ssize_t bound_axis = bound_axis_info.axis; assert(0 <= bound_axis && bound_axis < ndim); @@ -430,7 +430,7 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c } } -const std::vector& NumberNode::axis_wise_bounds() const { +const std::vector& NumberNode::axis_wise_bounds() const { return bound_axes_info_; } @@ -480,7 +480,7 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& /// Check the user defined axis-wise bounds for NumberNode. void check_axis_wise_bounds(const NumberNode* node) { - const std::vector& bound_axes_info = node->axis_wise_bounds(); + const std::vector& bound_axes_info = node->axis_wise_bounds(); if (bound_axes_info.size() == 0) return; // No bound axes to check. const std::span shape = node->shape(); @@ -488,7 +488,7 @@ void check_axis_wise_bounds(const NumberNode* node) { std::vector axis_bound(shape.size(), false); // For each set of bound axis data - for (const NumberNode::BoundAxisInfo& bound_axis_info : bound_axes_info) { + for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { const ssize_t axis = bound_axis_info.axis; if (axis < 0 || axis >= static_cast(shape.size())) { @@ -507,7 +507,7 @@ void check_axis_wise_bounds(const NumberNode* node) { "Invalid number of axis-wise bounds given number array shape."); } - // Checked in BoundAxisInfo constructor + // Checked in AxisBound constructor assert(num_operators == num_bounds || num_operators == 1 || num_bounds == 1); if (axis_bound[axis]) { @@ -531,7 +531,7 @@ void check_axis_wise_bounds(const NumberNode* node) { // Base class to be used as interfaces. NumberNode::NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound, std::vector bound_axes) + std::vector upper_bound, std::vector bound_axes) : ArrayOutputMixin(shape), min_(get_extreme_index_wise_bound(lower_bound)), max_(get_extreme_index_wise_bound(upper_bound)), @@ -579,10 +579,10 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, // Integer Node *************************************************************** /// Check the user defined axis-wise bounds for IntegerNode -void check_bound_axes_integrality(const std::vector& bound_axes_info) { +void check_bound_axes_integrality(const std::vector& bound_axes_info) { if (bound_axes_info.size() == 0) return; // No bound axes to check. - for (const NumberNode::BoundAxisInfo& bound_axis_info : bound_axes_info) { + for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { for (const double& bound : bound_axis_info.bounds) { if (bound != std::floor(bound)) { throw std::invalid_argument( @@ -595,7 +595,7 @@ void check_bound_axes_integrality(const std::vector& IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : NumberNode(shape, lower_bound.has_value() ? std::move(*lower_bound) : std::vector{default_lower_bound}, @@ -610,56 +610,56 @@ IntegerNode::IntegerNode(std::span shape, IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode({size}, std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(shape, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode({size}, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(shape, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::vector bound_axes) + double upper_bound, std::vector bound_axes) : IntegerNode({size}, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, - double upper_bound, std::vector bound_axes) + double upper_bound, std::vector bound_axes) : IntegerNode(std::span(shape), std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} @@ -722,63 +722,63 @@ std::vector limit_bound_to_bool_domain(std::optional BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : IntegerNode(shape, limit_bound_to_bool_domain(lower_bound), limit_bound_to_bool_domain(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode({size}, std::move(lower_bound), std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(shape, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode({size}, std::vector{lower_bound}, std::move(upper_bound), std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(shape, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::vector bound_axes) + double upper_bound, std::vector bound_axes) : BinaryNode({size}, std::move(lower_bound), std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode(std::span(shape), std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector bound_axes) : BinaryNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, std::move(bound_axes)) {} diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 1b3b6429..c9320741 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -36,28 +36,28 @@ from dwave.optimization.states cimport States # Convert the str operators "==", "<=", ">=" into their corresponding # C++ objects. -cdef NumberNode.BoundAxisOperator _parse_python_operator(str op) except *: +cdef NumberNode.AxisBound.Operator _parse_python_operator(str op) except *: if op == "==": - return NumberNode.BoundAxisOperator.Equal + return NumberNode.AxisBound.Operator.Equal elif op == "<=": - return NumberNode.BoundAxisOperator.LessEqual + return NumberNode.AxisBound.Operator.LessEqual elif op == ">=": - return NumberNode.BoundAxisOperator.GreaterEqual + return NumberNode.AxisBound.Operator.GreaterEqual else: raise TypeError(f"Invalid bound axis operator: {op!r}") # Convert the user-defined axis-wise bounds for NumberNode into the # corresponding C++ objects passed to NumberNode. -cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( +cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( bound_axes_data : None | list[tuple(int, str | list[str], float | list[float])]) except *: - cdef vector[NumberNode.BoundAxisInfo] output + cdef vector[NumberNode.AxisBound] output if bound_axes_data is None: return output output.reserve(len(bound_axes_data)) - cdef vector[NumberNode.BoundAxisOperator] cpp_ops + cdef vector[NumberNode.AxisBound.Operator] cpp_ops cdef vector[double] cpp_bounds cdef double[:] mem @@ -98,17 +98,17 @@ cdef vector[NumberNode.BoundAxisInfo] _convert_python_bound_axes( else: raise TypeError("Bound axis bound(s) should be scalar or 1D-array.") - output.push_back(NumberNode.BoundAxisInfo(axis, cpp_ops, cpp_bounds)) + output.push_back(NumberNode.AxisBound(axis, cpp_ops, cpp_bounds)) return output # Convert the C++ operators into their corresponding str -cdef str _parse_cpp_operators(NumberNode.BoundAxisOperator op): - if op == NumberNode.BoundAxisOperator.Equal: +cdef str _parse_cpp_operators(NumberNode.AxisBound.Operator op): + if op == NumberNode.AxisBound.Operator.Equal: return "==" - elif op == NumberNode.BoundAxisOperator.LessEqual: + elif op == NumberNode.AxisBound.Operator.LessEqual: return "<=" - elif op == NumberNode.BoundAxisOperator.GreaterEqual: + elif op == NumberNode.AxisBound.Operator.GreaterEqual: return ">=" else: raise ValueError(f"Invalid bound axis operator: {op!r}") @@ -128,7 +128,7 @@ cdef class BinaryVariable(ArraySymbol): cdef optional[vector[double]] cpplower_bound = nullopt cdef optional[vector[double]] cppupper_bound = nullopt - cdef vector[BinaryNode.BoundAxisInfo] cppbound_axes = _convert_python_bound_axes(subject_to) + cdef vector[BinaryNode.AxisBound] cppbound_axes = _convert_python_bound_axes(subject_to) cdef const double[:] mem if lower_bound is not None: @@ -247,7 +247,7 @@ cdef class BinaryVariable(ArraySymbol): def axis_wise_bounds(self): """Axis wise bound(s) of Binary symbol as a list of tuples where each tuple is of the form: (axis, [operator(s)], [bound(s)]).""" - cdef vector[NumberNode.BoundAxisInfo] bound_axes = self.ptr.axis_wise_bounds() + cdef vector[NumberNode.AxisBound] bound_axes = self.ptr.axis_wise_bounds() output = [] for i in range(bound_axes.size()): @@ -337,7 +337,7 @@ cdef class IntegerVariable(ArraySymbol): cdef optional[vector[double]] cpplower_bound = nullopt cdef optional[vector[double]] cppupper_bound = nullopt - cdef vector[BinaryNode.BoundAxisInfo] cppbound_axes = _convert_python_bound_axes(subject_to) + cdef vector[BinaryNode.AxisBound] cppbound_axes = _convert_python_bound_axes(subject_to) cdef const double[:] mem if lower_bound is not None: @@ -463,7 +463,7 @@ cdef class IntegerVariable(ArraySymbol): def axis_wise_bounds(self): """Axis wise bound(s) of Integer symbol as a list of tuples where each tuple is of the form: (axis, [operator(s)], [bound(s)]).""" - cdef vector[NumberNode.BoundAxisInfo] bound_axes = self.ptr.axis_wise_bounds() + cdef vector[NumberNode.AxisBound] bound_axes = self.ptr.axis_wise_bounds() output = [] for i in range(bound_axes.size()): diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 95a032eb..75bd109b 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -26,33 +26,33 @@ using Catch::Matchers::RangeEquals; namespace dwave::optimization { -using BoundAxisInfo = NumberNode::BoundAxisInfo; -using BoundAxisOperator = NumberNode::BoundAxisOperator; -using NumberNode::Equal; -using NumberNode::GreaterEqual; -using NumberNode::LessEqual; - -TEST_CASE("BoundAxisInfo") { - GIVEN("BoundAxisInfo(axis = 0, operators = {}, bounds = {1.0})") { - REQUIRE_THROWS_WITH(BoundAxisInfo(0, {}, {1.0}), +using AxisBound = NumberNode::AxisBound; +using Operator = NumberNode::AxisBound::Operator; +using NumberNode::AxisBound::Operator::Equal; +using NumberNode::AxisBound::Operator::GreaterEqual; +using NumberNode::AxisBound::Operator::LessEqual; + +TEST_CASE("AxisBound") { + GIVEN("AxisBound(axis = 0, operators = {}, bounds = {1.0})") { + REQUIRE_THROWS_WITH(AxisBound(0, {}, {1.0}), "Axis-wise `operators` and `bounds` must have non-zero size."); } - GIVEN("BoundAxisInfo(axis = 0, operators = {<=}, bounds = {})") { - REQUIRE_THROWS_WITH(BoundAxisInfo(0, {LessEqual}, {}), + GIVEN("AxisBound(axis = 0, operators = {<=}, bounds = {})") { + REQUIRE_THROWS_WITH(AxisBound(0, {LessEqual}, {}), "Axis-wise `operators` and `bounds` must have non-zero size."); } - GIVEN("BoundAxisInfo(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { + GIVEN("AxisBound(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { REQUIRE_THROWS_WITH( - BoundAxisInfo(1, {LessEqual, Equal, Equal}, {2.0, 1.0}), + AxisBound(1, {LessEqual, Equal, Equal}, {2.0, 1.0}), "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); } - GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { - std::vector operators{Equal, LessEqual, GreaterEqual}; + GIVEN("AxisBound(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{1.0}; - BoundAxisInfo bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0}); + AxisBound bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0}); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); @@ -61,10 +61,10 @@ TEST_CASE("BoundAxisInfo") { } } - GIVEN("BoundAxisInfo(axis = 2, operators = {==}, bounds = {1.0, 2.0, 3.0})") { - std::vector operators{Equal}; + GIVEN("AxisBound(axis = 2, operators = {==}, bounds = {1.0, 2.0, 3.0})") { + std::vector operators{Equal}; std::vector bounds{1.0, 2.0, 3.0}; - BoundAxisInfo bound_axis(2, operators, bounds); + AxisBound bound_axis(2, operators, bounds); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); @@ -73,10 +73,10 @@ TEST_CASE("BoundAxisInfo") { } } - GIVEN("BoundAxisInfo(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { - std::vector operators{Equal, LessEqual, GreaterEqual}; + GIVEN("AxisBound(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { + std::vector operators{Equal, LessEqual, GreaterEqual}; std::vector bounds{1.0, 2.0, 3.0}; - BoundAxisInfo bound_axis(2, operators, bounds); + AxisBound bound_axis(2, operators, bounds); THEN("The bound axis info is correct") { CHECK(bound_axis.axis == 2); @@ -502,7 +502,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { - std::vector bound_axes{{-1, {Equal}, {1.0}}}; + std::vector bound_axes{{-1, {Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -510,7 +510,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { - std::vector bound_axes{{2, {Equal}, {1.0}}}; + std::vector bound_axes{{2, {Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -518,7 +518,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many operators.") { - std::vector bound_axes{{1, {LessEqual, Equal, Equal, Equal}, {1.0}}}; + std::vector bound_axes{{1, {LessEqual, Equal, Equal, Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -526,7 +526,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector bound_axes{{1, {LessEqual, Equal}, {1.0}}}; + std::vector bound_axes{{1, {LessEqual, Equal}, {1.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -534,7 +534,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too many bounds.") { - std::vector bound_axes{{1, {Equal}, {1.0, 2.0, 3.0, 4.0}}}; + std::vector bound_axes{{1, {Equal}, {1.0, 2.0, 3.0, 4.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -542,7 +542,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector bound_axes{{1, {LessEqual}, {1.0, 2.0}}}; + std::vector bound_axes{{1, {LessEqual}, {1.0, 2.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -550,8 +550,8 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { - BoundAxisInfo bound_axis{1, {Equal}, {1.0}}; - std::vector bound_axes{bound_axis, bound_axis}; + AxisBound bound_axis{1, {Equal}, {1.0}}; + std::vector bound_axes{bound_axis, bound_axis}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -559,9 +559,9 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { - BoundAxisInfo bound_axis_0{0, {LessEqual}, {1.0}}; - BoundAxisInfo bound_axis_1{1, {LessEqual}, {1.0}}; - std::vector bound_axes{bound_axis_0, bound_axis_1}; + AxisBound bound_axis_0{0, {LessEqual}, {1.0}}; + AxisBound bound_axis_1{1, {LessEqual}, {1.0}}; + std::vector bound_axes{bound_axis_0, bound_axis_1}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -569,7 +569,7 @@ TEST_CASE("BinaryNode") { } GIVEN("(2x3x4)-BinaryNode with non-integral axis-wise bounds") { - std::vector bound_axes{{1, {Equal}, {0.1}}}; + std::vector bound_axes{{1, {Equal}, {0.1}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -578,8 +578,7 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector bound_axes{ - {0, {Equal, LessEqual, GreaterEqual}, {5.0, 2.0, 3.0}}}; + std::vector bound_axes{{0, {Equal, LessEqual, GreaterEqual}, {5.0, 2.0, 3.0}}}; // Each hyperslice along axis 0 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 0) that results in a // sum equal to 5. @@ -590,7 +589,7 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector bound_axes{{1, {Equal, GreaterEqual}, {5.0, 7.0}}}; + std::vector bound_axes{{1, {Equal, GreaterEqual}, {5.0, 7.0}}}; // Each hyperslice along axis 1 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 1) that results in a // sum greater than or equal to 7. @@ -601,7 +600,7 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector bound_axes{{2, {Equal, LessEqual}, {5.0, -1.0}}}; + std::vector bound_axes{{2, {Equal, LessEqual}, {5.0, -1.0}}}; // Each hyperslice along axis 2 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 2) that results in a // sum less than or equal to -1. @@ -614,14 +613,13 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0}; std::vector upper_bounds{0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1}; - std::vector bound_axes{ - {0, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}}}; + std::vector bound_axes{{0, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -660,13 +658,13 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}; std::vector upper_bounds{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; - std::vector bound_axes{{1, {LessEqual, GreaterEqual}, {1.0, 5.0}}}; + std::vector bound_axes{{1, {LessEqual, GreaterEqual}, {1.0, 5.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -704,13 +702,13 @@ TEST_CASE("BinaryNode") { auto graph = Graph(); std::vector lower_bounds{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0}; std::vector upper_bounds{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; - std::vector bound_axes{{2, {Equal, GreaterEqual}, {3.0, 6.0}}}; + std::vector bound_axes{{2, {Equal, GreaterEqual}, {3.0, 6.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, lower_bounds, upper_bounds, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -746,14 +744,13 @@ TEST_CASE("BinaryNode") { GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector bound_axes{ - {0, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}}}; + std::vector bound_axes{{0, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1310,7 +1307,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { - std::vector bound_axes{{-2, {Equal}, {20.0}}}; + std::vector bound_axes{{-2, {Equal}, {20.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, std::nullopt, bound_axes), @@ -1318,7 +1315,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { - std::vector bound_axes{{3, {Equal}, {10.0}}}; + std::vector bound_axes{{3, {Equal}, {10.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1326,7 +1323,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many operators.") { - std::vector bound_axes{{1, {LessEqual, Equal, Equal, Equal}, {-10.0}}}; + std::vector bound_axes{{1, {LessEqual, Equal, Equal, Equal}, {-10.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1334,7 +1331,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector bound_axes{{1, {LessEqual, Equal}, {-11.0}}}; + std::vector bound_axes{{1, {LessEqual, Equal}, {-11.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1342,7 +1339,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too many bounds.") { - std::vector bound_axes{{1, {LessEqual}, {-10.0, 20.0, 30.0, 40.0}}}; + std::vector bound_axes{{1, {LessEqual}, {-10.0, 20.0, 30.0, 40.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1350,7 +1347,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector bound_axes{{1, {LessEqual}, {111.0, -223.0}}}; + std::vector bound_axes{{1, {LessEqual}, {111.0, -223.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1358,7 +1355,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { - std::vector bound_axes{{1, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; + std::vector bound_axes{{1, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1366,7 +1363,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { - std::vector bound_axes{{0, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; + std::vector bound_axes{{0, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1374,7 +1371,7 @@ TEST_CASE("IntegerNode") { } GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - std::vector bound_axes{{1, {LessEqual}, {11.0, 12.0001, 0.0}}}; + std::vector bound_axes{{1, {LessEqual}, {11.0, 12.0001, 0.0}}}; REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3, 4}, std::nullopt, std::nullopt, bound_axes), @@ -1383,7 +1380,7 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector bound_axes{{0, {Equal, LessEqual}, {5.0, -31.0}}}; + std::vector bound_axes{{0, {Equal, LessEqual}, {5.0, -31.0}}}; // Each hyperslice along axis 0 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 0) that results in a // sum less than or equal to -5*6 - 1 = -31. @@ -1394,7 +1391,7 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector bound_axes{{1, {GreaterEqual, Equal, Equal}, {33.0, 0.0, 0.0}}}; + std::vector bound_axes{{1, {GreaterEqual, Equal, Equal}, {33.0, 0.0, 0.0}}}; // Each hyperslice along axis 1 has size 4. There is no feasible // assignment to the values in slice 0 (along axis 1) that results in a // sum greater than or equal to 4*8 + 1 = 33. @@ -1405,7 +1402,7 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector bound_axes{{2, {GreaterEqual, Equal}, {-1.0, 49.0}}}; + std::vector bound_axes{{2, {GreaterEqual, Equal}, {-1.0, 49.0}}}; // Each hyperslice along axis 2 has size 6. There is no feasible // assignment to the values in slice 1 (along axis 2) that results in a // sum or equal to 6*8 + 1 = 49 @@ -1416,13 +1413,13 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { auto graph = Graph(); - std::vector bound_axes{{0, {Equal, GreaterEqual}, {-21.0, 9.0}}}; + std::vector bound_axes{{0, {Equal, GreaterEqual}, {-21.0, 9.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1459,14 +1456,13 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector bound_axes{ - {1, {Equal, GreaterEqual, LessEqual}, {0.0, -2.0, 0.0}}}; + std::vector bound_axes{{1, {Equal, GreaterEqual, LessEqual}, {0.0, -2.0, 0.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1506,13 +1502,13 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { auto graph = Graph(); - std::vector bound_axes{{2, {Equal, GreaterEqual}, {23.0, 14.0}}}; + std::vector bound_axes{{2, {Equal, GreaterEqual}, {23.0, 14.0}}}; auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - BoundAxisInfo bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; CHECK(bound_axes[0].axis == bnode_bound_axis.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(bnode_bound_axis.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(bnode_bound_axis.bounds)); @@ -1549,14 +1545,13 @@ TEST_CASE("IntegerNode") { GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { auto graph = Graph(); - std::vector bound_axes{ - {1, {Equal, LessEqual, GreaterEqual}, {11.0, 2.0, 5.0}}}; + std::vector bound_axes{{1, {Equal, LessEqual, GreaterEqual}, {11.0, 2.0, 5.0}}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, -5, 8, bound_axes); THEN("Axis wise bound is correct") { CHECK(inode_ptr->axis_wise_bounds().size() == 1); - const BoundAxisInfo inode_bound_axis_ptr = inode_ptr->axis_wise_bounds().data()[0]; + const AxisBound inode_bound_axis_ptr = inode_ptr->axis_wise_bounds().data()[0]; CHECK(bound_axes[0].axis == inode_bound_axis_ptr.axis); CHECK_THAT(bound_axes[0].operators, RangeEquals(inode_bound_axis_ptr.operators)); CHECK_THAT(bound_axes[0].bounds, RangeEquals(inode_bound_axis_ptr.bounds)); From c6a93d5021e63ff6412793426a51aa2ce3450de4 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 16:46:18 -0800 Subject: [PATCH 22/22] Address 1st rnd. comments NumberNode axis-wise bounds --- .../dwave-optimization/nodes/numbers.hpp | 3 +- dwave/optimization/libcpp/nodes/numbers.pxd | 6 +-- dwave/optimization/model.py | 37 ++++++++++--------- dwave/optimization/src/nodes/numbers.cpp | 29 +++++++-------- dwave/optimization/symbols/numbers.pyx | 35 +++++++----------- tests/test_symbols.py | 8 +++- 6 files changed, 57 insertions(+), 61 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 3d500bf7..953f43cc 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -144,7 +144,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { const std::vector& axis_wise_bounds() const; /// Return the state-dependent sum of the values within each hyperslice - /// along each bound axis. + /// along each bound axis. The returned vector is indexed by the + /// bound axes in the same ordering that `axis_wise_bounds()` returns. const std::vector>& bound_axis_sums(State& state) const; protected: diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index 9bef9a52..78d8d61d 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -30,10 +30,10 @@ cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimi GreaterEqual AxisBound(Py_ssize_t axis, vector[Operator] axis_opertors, - vector[double] axis_bounds) + vector[double] axis_bounds) Py_ssize_t axis - vector[Operator] operators; - vector[double] bounds; + vector[Operator] operators + vector[double] bounds void initialize_state(State&, vector[double]) except+ double lower_bound(Py_ssize_t index) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index d723d25f..5a845b26 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -166,7 +166,8 @@ def objective(self, value: ArraySymbol): def binary(self, shape: None | _ShapeLike = None, lower_bound: None | np.typing.ArrayLike = None, upper_bound: None | np.typing.ArrayLike = None, - subject_to: None | np.typing.ArrayLike = None) -> BinaryVariable: + subject_to: None | list[tuple(int, str | list[str], float | + list[float])] = None) -> BinaryVariable: r"""Create a binary symbol as a decision variable. Args: @@ -183,13 +184,13 @@ def binary(self, shape: None | _ShapeLike = None, array of tuples where each tuple has the form: (axis, operators, bounds) - axis (int): The axis along which the bounds are applied. - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). - A single operator applies to all hyperslices along the axis; an - array specifies one operator per hyperslice. + A single operator applies to all slices along the axis; an + array specifies one operator per slice. - bounds (float | array[float]): The bound value(s). A single value - applies to all hyperslices; an array specifies one bound per hyperslice. - If provided, the sum of values within each hyperslice along the specified - axis must satisfy the corresponding operator–bound pair. - Note: At most one axis-wise bound may be provided. + applies to all slices; an array specifies one bound per slice. + If provided, the sum of values within each slice along the + specified axis must satisfy the corresponding operator–bound + pair. Note: At most one axis-wise bound may be provided. Returns: A binary symbol. @@ -230,13 +231,13 @@ def binary(self, shape: None | _ShapeLike = None, This example adds a :math:`(2x3)`-sized binary symbol with index-wise lower bounds and an axis-wise bound along axis 1. Let x_i (int i : 0 <= i <= 2) denote the sum of the values within - hyperslice i along axis 1. For each state defined for this symbol: + slice i along axis 1. For each state defined for this symbol: (x_0 <= 0), (x_1 == 2), and (x_2 >= 1). >>> from dwave.optimization.model import Model >>> import numpy as np >>> model = Model() - >>> n = model.binary([2, 3], lower_bound=[[0, 1, 1], [0, 1, 0]], + >>> b = model.binary([2, 3], lower_bound=[[0, 1, 1], [0, 1, 0]], ... subject_to=[(1, ["<=", "==", ">="], [0, 2, 1])]) >>> np.all(n.axis_wise_bounds() == [(1, ["<=", "==", ">="], [0, 2, 1])]) True @@ -508,8 +509,8 @@ def integer( shape: None | _ShapeLike = None, lower_bound: None | numpy.typing.ArrayLike = None, upper_bound: None | numpy.typing.ArrayLike = None, - subject_to: None | np.typing.ArrayLike = None - ) -> IntegerVariable: + subject_to: None | list[tuple(int, str | list[str], float | + list[float])] = None) -> IntegerVariable: r"""Create an integer symbol as a decision variable. Args: @@ -526,13 +527,13 @@ def integer( array of tuples where each tuple has the form: (axis, operators, bounds) - axis (int): The axis along which the bounds are applied. - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). - A single operator applies to all hyperslices along the axis; an - array specifies one operator per hyperslice. + A single operator applies to all slice along the axis; an array + specifies one operator per slice. - bounds (float | array[float]): The bound value(s). A single value - applies to all hyperslices; an array specifies one bound per hyperslice. - If provided, the sum of values within each hyperslice along the specified - axis must satisfy the corresponding operator–bound pair. - Note: At most one axis-wise bound may be provided. + applies to all slices; an array specifies one bound per slice. + If provided, the sum of values within each slice along the + specified axis must satisfy the corresponding operator–bound + pair. Note: At most one axis-wise bound may be provided. Returns: An integer symbol. @@ -574,7 +575,7 @@ def integer( This example adds a :math:`(2x3)`-sized integer symbol with general lower and upper bounds and an axis-wise bound along axis 1. Let x_i (int i : 0 <= i <= 2) denote the sum of the values within - hyperslice i along axis 1. For each state defined for this symbol: + slice i along axis 1. For each state defined for this symbol: (x_0 <= 2), (x_1 <= 4), and (x_2 <= 5). >>> from dwave.optimization.model import Model diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 3b7fada7..f8ae7f68 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -177,19 +177,18 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat if (bound_axes_info_.size() == 0) { // No bound axes to consider. emplace_data_ptr(state, std::move(number_data)); - return; - } + } else { + // Given the assingnment to NumberNode `number_data`, compute the sum of the + // values within each hyperslice along each bound axis. + std::vector> bound_axes_sums = get_bound_axes_sums(this, number_data); - // Given the assingnment to NumberNode `number_data`, compute the sum of the - // values within each hyperslice along each bound axis. - std::vector> bound_axes_sums = get_bound_axes_sums(this, number_data); + if (!satisfies_axis_wise_bounds(bound_axes_info_, bound_axes_sums)) { + throw std::invalid_argument("Initialized values do not satisfy axis-wise bounds."); + } - if (!satisfies_axis_wise_bounds(bound_axes_info_, bound_axes_sums)) { - throw std::invalid_argument("Initialized values do not satisfy axis-wise bounds."); + emplace_data_ptr(state, std::move(number_data), + std::move(bound_axes_sums)); } - - emplace_data_ptr(state, std::move(number_data), - std::move(bound_axes_sums)); } /// Given a `span` (used for strides or shape data), reorder the values @@ -282,8 +281,8 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, const std::vector buff_strides = shift_axis_data(node->strides(), bound_axis); // Define an iterator for `values` corresponding with the beginning of // slice 0 along the bound axis. - BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), - buff_strides.data()); + const BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), + buff_strides.data()); // Determine the size of each hyperslice along the bound axis. const ssize_t slice_size = std::accumulate(buff_shape.begin() + 1, buff_shape.end(), 1.0, std::multiplies()); @@ -336,14 +335,12 @@ void NumberNode::initialize_state(State& state) const { values.push_back(default_value(i)); } initialize_state(state, std::move(values)); - return; } else if (bound_axes_info_.size() == 1) { construct_state_given_exactly_one_bound_axis(this, values); initialize_state(state, std::move(values)); - return; + } else { + unreachable(); } - - throw std::invalid_argument("Cannot initialize state with multiple bound axes."); } void NumberNode::commit(State& state) const noexcept { diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index c9320741..15754bcf 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -16,6 +16,7 @@ import json +import collections.abc import numpy as np from cython.operator cimport typeid @@ -71,25 +72,20 @@ cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( if not isinstance(axis, int): raise TypeError("Bound axis must be an int.") - cpp_ops.clear() if isinstance(py_ops, str): + cpp_ops.resize(1) # One operator defined for all slices. - cpp_ops.push_back(_parse_python_operator(py_ops)) - else: + cpp_ops[0] = _parse_python_operator(py_ops) + elif isinstance(py_ops, collections.abc.Iterable): # Operator defined per slice. - ops_array = np.asarray(py_ops, order='C') - if (ops_array.ndim <= 1): - cpp_ops.reserve(ops_array.size) - for op in ops_array: - # Convert op to `str` because _parse_python_operator() - # does not expect a `numpy.str_`. - cpp_ops.push_back(_parse_python_operator(str(op))) - else: - raise TypeError("Bound axis operator(s) should be str or" - " a 1D-array of str(s).") + cpp_ops.reserve(len(py_ops)) + for op in py_ops: + cpp_ops.push_back(_parse_python_operator(op)) + else: + raise TypeError("Bound axis operator(s) should be str or a 1D-array" + " of str(s).") - cpp_bounds.clear() - bound_array = np.asarray_chkfinite(py_bounds, dtype=np.double, order='C') + bound_array = np.asarray_chkfinite(py_bounds, dtype=np.double) if (bound_array.ndim <= 1): mem = bound_array.ravel() cpp_bounds.reserve(mem.shape[0]) @@ -98,7 +94,7 @@ cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( else: raise TypeError("Bound axis bound(s) should be scalar or 1D-array.") - output.push_back(NumberNode.AxisBound(axis, cpp_ops, cpp_bounds)) + output.push_back(NumberNode.AxisBound(axis, move(cpp_ops), move(cpp_bounds))) return output @@ -206,10 +202,9 @@ cdef class BinaryVariable(ArraySymbol): subject_to = None else: with zf.open(info, "r") as f: - subject_to = json.load(f) # Note that import is a list of lists, not a list of tuples. # Hence we convert to tuple. We could also support lists. - subject_to = [(axis, ops, bounds) for axis, ops, bounds in subject_to] + subject_to = [(axis, ops, bounds) for axis, ops, bounds in json.load(f)] return BinaryVariable(model, shape=shape_info["shape"], @@ -415,11 +410,9 @@ cdef class IntegerVariable(ArraySymbol): subject_to = None else: with zf.open(info, "r") as f: - # Note that import is a list of lists, not a list of tuples - subject_to = json.load(f) # Note that import is a list of lists, not a list of tuples. # Hence we convert to tuple. We could also support lists. - subject_to = [(axis, ops, bounds) for axis, ops, bounds in subject_to] + subject_to = [(axis, ops, bounds) for axis, ops, bounds in json.load(f)] return IntegerVariable(model, shape=shape_info["shape"], diff --git a/tests/test_symbols.py b/tests/test_symbols.py index f548ca9b..1966c2f4 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -742,7 +742,7 @@ def test_axis_wise_bounds(self): self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1])]) x = model.binary((2, 3), subject_to=[(0, "<=", 1)]) self.assertEqual(x.axis_wise_bounds(), [(0, ["<="], [1])]) - x = model.binary((2, 3), subject_to=[(0, np.asarray(["<=", "=="]), np.asarray([1, 2]))]) + x = model.binary((2, 3), subject_to=[(0, ["<=", "=="], np.asarray([1, 2]))]) self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) # infeasible axis-wise bounds @@ -1924,7 +1924,7 @@ def test_axis_wise_bounds(self): self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1])]) x = model.integer((2, 3), subject_to=[(0, "<=", 1)]) self.assertEqual(x.axis_wise_bounds(), [(0, ["<="], [1])]) - x = model.integer((2, 3), subject_to=[(0, np.asarray(["<=", "=="]), np.asarray([1, 2]))]) + x = model.integer((2, 3), subject_to=[(0, ["<=", "=="], np.asarray([1, 2]))]) self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) # infeasible axis-wise bounds @@ -1953,6 +1953,10 @@ def test_axis_wise_bounds(self): with self.assertRaises(TypeError): model.integer((2, 3), subject_to=[(1, [["=="]], [0, 0, 0])]) + # invalid number of bound axes + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[(0, "==", 1), (1, "<=", [1, 1, 1])]) + # Todo: we can generalize many of these tests for all decisions that can have # their state set