From dda8389e307936e8e573fdf8a28780c7bbb07293 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 6 Jan 2026 09:31:39 -0800 Subject: [PATCH 01/31] 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 89ef526a..307a5a48 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 1665bd69..ad8e0fb8 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 8297e039..df74f4b8 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 a9df10656358738463abf67f9de944883121ad39 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 6 Jan 2026 13:06:03 -0800 Subject: [PATCH 02/31] 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 | 21 +- dwave/optimization/src/nodes/numbers.cpp | 420 ++++++++----- tests/cpp/nodes/test_numbers.cpp | 550 +++++++++++++++++- 3 files changed, 822 insertions(+), 169 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 307a5a48..163a4096 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; @@ -136,27 +135,33 @@ 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* get_ith_bound_axis_info(const ssize_t i) 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; + /// 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 ad8e0fb8..6a323105 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -15,19 +15,24 @@ #include "dwave-optimization/nodes/numbers.hpp" #include +#include +#include #include +#include #include #include +#include #include "_state.hpp" +#include "dwave-optimization/array.hpp" 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)) { @@ -45,31 +50,236 @@ 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)); + 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 { + assert(0 <= slice); + if (operators.size() == 0) return operators[0]; + assert(slice < static_cast(operators.size())); + return operators[slice]; +} + +/// State dependant data attached to NumberNode +struct NumberNodeStateData : public ArrayNodeStateData { + 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; +}; + +double const* NumberNode::buff(const State& state) const noexcept { + return data_ptr(state)->buff(); +} + +std::span NumberNode::diff(const State& state) const noexcept { + return data_ptr(state)->diff(); +} + +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); } - if (max_slice == 1) { - return bounds[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 bounds[slice]; + + return bound_axes_sums; } -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)); +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; +} - if (max_slice == 1) { - return operators[0]; +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"); } - return operators[slice]; + + 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"); + } + } + + if (bound_axes_info_.size() == 0) { // No bound axes to consider. + emplace_data_ptr(state, std::move(number_data)); + return; + } + + 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 { + std::vector values; + values.reserve(this->size()); + 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)); +} + +void NumberNode::commit(State& state) const noexcept { + auto node_data = data_ptr(state); + // 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); + // 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 { + 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)); + 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]). + 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(bound_axes_info_, ptr->bound_axes_sums)); + } +} + +double NumberNode::get_value(State& state, ssize_t i) const { + return data_ptr(state)->get(i); +} + +double NumberNode::lower_bound(ssize_t index) const { + if (lower_bounds_.size() == 1) { + return lower_bounds_[0]; + } + assert(0 <= index && index < static_cast(lower_bounds_.size())); + return lower_bounds_[index]; +} + +double NumberNode::lower_bound() const { + if (lower_bounds_.size() > 1) { + throw std::out_of_range( + "Number array has multiple lower bounds, use lower_bound(index) instead"); + } + return lower_bounds_[0]; +} + +double NumberNode::upper_bound(ssize_t index) const { + if (upper_bounds_.size() == 1) { + return upper_bounds_[0]; + } + assert(0 <= index && index < static_cast(upper_bounds_.size())); + return upper_bounds_[index]; +} + +double NumberNode::upper_bound() const { + if (upper_bounds_.size() > 1) { + throw std::out_of_range( + "Number array has multiple upper bounds, use upper_bound(index) instead"); + } + return upper_bounds_[0]; +} + +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] . + 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)); + } +} + +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; } template @@ -115,9 +325,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, const std::span shape) { - if (bound_axes_info.size() == 0) { // No bound axes to check. - return; - } + if (bound_axes_info.size() == 0) return; // No bound axes to check. // Used to asses if an axis have been bound multiple times. std::vector axis_bound(shape.size(), false); @@ -126,26 +334,26 @@ void check_axis_wise_bounds(const std::vector& bound_axes_info, for (const BoundAxisInfo& bound_axis_info : bound_axes_info) { const ssize_t axis = bound_axis_info.axis; - if (axis < 0 || axis >= shape.size()) { + 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."); } // The number of operators defined for the given bound axis - const ssize_t num_operators = bound_axis_info.operators.size(); + 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 shape: " + std::to_string(shape[axis])); + " given axis size: " + 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(); + 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 shape: " + std::to_string(shape[axis])); + " given axis size: " + std::to_string(shape[axis])); } // Checked in BoundAxisInfo constructor @@ -186,118 +394,46 @@ NumberNode::NumberNode(std::span shape, std::vector lower check_axis_wise_bounds(bound_axes_info_, this->shape()); } -double const* NumberNode::buff(const State& state) const noexcept { - return data_ptr(state)->buff(); -} - -std::span NumberNode::diff(const State& state) const noexcept { - return data_ptr(state)->diff(); -} - -double NumberNode::min() const { return min_; } - -double NumberNode::max() const { return max_; } - -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)); -} - -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)); - } - initialize_state(state, std::move(values)); -} - -void NumberNode::commit(State& state) const noexcept { - data_ptr(state)->commit(); -} - -void NumberNode::revert(State& state) const noexcept { - data_ptr(state)->revert(); -} - -void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { - 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)); - 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]). - ptr->exchange(i, j); -} - -double NumberNode::get_value(State& state, ssize_t i) const { - return data_ptr(state)->get(i); -} - -double NumberNode::lower_bound(ssize_t index) const { - if (lower_bounds_.size() == 1) { - return lower_bounds_[0]; +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 + + // 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. + 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; } - assert(lower_bounds_.size() > 1); - assert(0 <= index && index < static_cast(lower_bounds_.size())); - return lower_bounds_[index]; } -double NumberNode::lower_bound() const { - if (lower_bounds_.size() > 1) { - throw std::out_of_range( - "Number array has multiple lower bounds, use lower_bound(index) instead"); - } - return lower_bounds_[0]; -} +// Integer Node *************************************************************** -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]; -} +/// 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. -double NumberNode::upper_bound() const { - if (upper_bounds_.size() > 1) { - throw std::out_of_range( - "Number array has multiple upper bounds, use upper_bound(index) instead"); + 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."); + } + } } - return upper_bounds_[0]; -} - -void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) const { - 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); } -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]; -}; - -// Integer Node *************************************************************** - IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, @@ -311,6 +447,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, @@ -377,13 +515,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(bound_axes_info_, ptr->bound_axes_sums)); + } } double IntegerNode::default_value(ssize_t index) const { @@ -484,12 +627,17 @@ 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(). - // 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(bound_axes_info_, ptr->bound_axes_sums)); + } } } // namespace dwave::optimization diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index df74f4b8..08ded6ff 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,23 +565,260 @@ 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") { - BoundAxisInfo bound_axis{1, std::vector{LessEqual}, - std::vector{1.0, 1.0, 0.0}}; + GIVEN("(2x3x4)-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}}; 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->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"); + 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 three invalid states") { + auto state = graph.empty_state(); + // 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.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 valid state") { + auto state = graph.empty_state(); + 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); + + 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); + } + } + } + } } } } @@ -880,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 8ba87ada85697542f99b8b189fcdae7ef6933715 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 28 Jan 2026 16:10:14 -0800 Subject: [PATCH 03/31] 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 163a4096..113fe5d3 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 6a323105..27661740 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 08ded6ff..ed5c9f17 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 bf586504feae1e48f2c3142ce47d33f8e0d6969c Mon Sep 17 00:00:00 2001 From: fastbodin Date: Thu, 29 Jan 2026 15:35:47 -0800 Subject: [PATCH 04/31] 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 | 67 +-- dwave/optimization/src/nodes/numbers.cpp | 288 +++++----- tests/cpp/nodes/test_numbers.cpp | 543 +++++++++--------- 3 files changed, 467 insertions(+), 431 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 113fe5d3..51bf3357 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 `axis`. + 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; + }; + NumberNode() = delete; // Overloads needed by the Array ABC ************************************** @@ -97,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"); } @@ -139,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: @@ -156,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 27661740..c2a4d41c 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -29,35 +29,33 @@ 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()); + 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."); } } -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())); @@ -92,37 +90,38 @@ 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 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); } - // 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; } } @@ -130,24 +129,26 @@ std::vector> get_bound_axes_sums(const NumberNode* node, return bound_axes_sums; } -bool satisfies_axis_wise_bounds(const std::vector& bound_axes_info, +/// 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 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) { 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: @@ -174,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)) { @@ -184,95 +187,123 @@ 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) { + 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) 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) { + 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(); + } +} + +/// 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 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. + // 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 BoundAxisInfo& bound_axis_info = node->axis_wise_bounds()[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`. - 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()); + // 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 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 + // value 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); + 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. + 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."); } } @@ -286,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 { @@ -317,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. @@ -370,16 +400,17 @@ 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)); } } -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 +457,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,29 +465,25 @@ 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())) { - 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 @@ -512,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; } @@ -524,10 +552,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( @@ -623,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)); } @@ -733,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. diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index ed5c9f17..7db46e36 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -28,44 +28,50 @@ 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."); + 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( - BoundAxisInfo(0, std::vector{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( - 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."); + 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})") { - BoundAxisInfo bound_axis(2, std::vector{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({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})") { - BoundAxisInfo bound_axis(2, std::vector{Equal, LessEqual, 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({Equal, LessEqual, 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)); } } } @@ -486,164 +492,168 @@ 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."); + 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") { - 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."); + 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.") { - 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 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.") { - 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 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.") { - 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"); + 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.") { - 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"); + 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") { - BoundAxisInfo bound_axis{1, std::vector{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}), + 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}}; + 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}), + 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}), + 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(); - - BoundAxisInfo bound_axis{0, std::vector{Equal, LessEqual, 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(), "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}}; - - 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 // 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}}; - - 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 // 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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We create a state by initialize_state()") { @@ -676,20 +686,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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We create a state by initialize_state()") { @@ -721,20 +730,19 @@ 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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We create a state by initialize_state()") { @@ -766,20 +774,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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We initialize three invalid states") { @@ -1040,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()); } @@ -1319,164 +1327,165 @@ 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."); + 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") { - 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."); + 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.") { - 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"); + 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.") { - 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"); + 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.") { - 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"); + 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.") { - 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"); + 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") { - BoundAxisInfo bound_axis{1, std::vector{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}), + 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}}; + 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}), + 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}), + 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(); - - 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}); + 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 // 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}}; - - 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 // 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}}; - - 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 // 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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We create a state by initialize_state()") { @@ -1509,20 +1518,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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We create a state by initialize_state()") { @@ -1558,20 +1566,19 @@ 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}}; - - 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); - 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)); + NumberNode::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)); } WHEN("We create a state by initialize_state()") { @@ -1604,20 +1611,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}}; - - 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 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)); + const NumberNode::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)); } WHEN("We initialize three invalid states") { From 27716fcfc9cff71f6e57f85790a273768f1ccf1e Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 2 Feb 2026 09:49:38 -0800 Subject: [PATCH 05/31] 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 c2a4d41c..26252fe8 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 7db46e36..4e89a3c5 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 a1908a1e6011d83e5feea52a9ad2e5d5563daa72 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 2 Feb 2026 13:02:14 -0800 Subject: [PATCH 06/31] 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 51bf3357..699c4021 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 26252fe8..576944fb 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 4e89a3c5..ca87702d 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 65bc25bb650177add07c12e9cbde3ce6513d5d4b Mon Sep 17 00:00:00 2001 From: fastbodin Date: Mon, 2 Feb 2026 13:06:19 -0800 Subject: [PATCH 07/31] 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 699c4021..84b91394 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 576944fb..e7f0a10c 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 ca87702d..a6422909 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 27354088bf935057bbf1759ab8a6952188361f04 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 11:08:40 -0800 Subject: [PATCH 08/31] 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 e7f0a10c..92806ded 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 a6422909..43678329 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 51c8c7273256461a8f29ef666007a84f3b84f1c2 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 13:01:02 -0800 Subject: [PATCH 09/31] 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 92806ded..81eb21f4 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 efc1c275614aa25ee075a6cbaf5300105a36cbce Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 13:25:03 -0800 Subject: [PATCH 10/31] Expose NumberNode axis-wise bounds to Python Resolved conflict in rebase. --- dwave/optimization/libcpp/nodes/numbers.pxd | 29 +++- dwave/optimization/model.py | 75 ++++++++++- dwave/optimization/symbols/numbers.pyx | 116 +++++++++++++++- tests/test_symbols.py | 139 +++++++++++++++++++- 4 files changed, 338 insertions(+), 21 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 cd53ecd0..8140e9d2 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,16 +229,37 @@ 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`: The created symbol and its methods. - .. 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. @@ -488,6 +523,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. @@ -501,6 +537,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. @@ -539,15 +588,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 5465a1ee..62b8ffdf 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,12 +28,83 @@ 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. @@ -40,13 +112,15 @@ cdef class BinaryVariable(ArraySymbol): :meth:`~dwave.optimization.model.Model.binary`: Instantiation and usage of this symbol. """ - 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: @@ -76,7 +150,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) @@ -144,6 +218,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 the symbol.""" try: @@ -218,13 +308,15 @@ cdef class IntegerVariable(ArraySymbol): :meth:`~dwave.optimization.model.Model.integer`: Instantiation and usage of this symbol. """ - 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: @@ -254,7 +346,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) @@ -328,6 +420,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 the symbol.""" try: diff --git a/tests/test_symbols.py b/tests/test_symbols.py index ce7d7094..99de5d0e 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -729,7 +729,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) @@ -748,6 +748,47 @@ def test_bounds(self): with self.assertRaises(ValueError): 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() x = model.binary() @@ -816,7 +857,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]) @@ -826,6 +867,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) @@ -1848,7 +1908,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) @@ -1867,6 +1927,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 @@ -1922,7 +2023,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) @@ -1933,6 +2034,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) @@ -1961,6 +2081,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 74d321e4df74f13f25c85edf31a4847036947bca Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:01:43 -0800 Subject: [PATCH 11/31] 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 8140e9d2..cbbd807e 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 @@ -538,8 +538,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 62b8ffdf..685d1f94 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') @@ -191,10 +193,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): @@ -218,6 +230,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)]).""" @@ -387,10 +403,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): @@ -420,6 +446,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 99de5d0e..b8db4c2e 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -824,6 +824,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() @@ -835,6 +837,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"): @@ -1988,6 +1991,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() @@ -1999,6 +2004,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 f1457f64ac13da5006de5c9b43b69b86e55cdf20 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:07:51 -0800 Subject: [PATCH 12/31] 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 cbbd807e..610bde05 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. @@ -538,18 +536,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 9bbfde678f39ae42c9c358da7c13995c65916c3e Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:09:16 -0800 Subject: [PATCH 13/31] 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 5176ac5a8346da7df31bf724bfea9ac54d7d36eb Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 15:24:11 -0800 Subject: [PATCH 14/31] 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 84b91394..92039102 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 610bde05..4f22919a 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -252,6 +252,10 @@ def binary(self, shape: None | _ShapeLike = None, :class:`~dwave.optimization.symbols.numbers.BinaryVariable`: The created symbol and its methods. + .. 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 685d1f94..b81eaf30 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)") @@ -200,6 +201,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, @@ -232,6 +234,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): @@ -410,6 +413,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, @@ -448,6 +452,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 b8db4c2e..a46b97d9 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -2087,17 +2087,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 db766265bf859702d62588c6cac636d404c65b74 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 3 Feb 2026 16:01:34 -0800 Subject: [PATCH 15/31] 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 4f22919a..882e9695 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. @@ -540,14 +540,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 b81eaf30..05afca93 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 @@ -201,8 +200,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"], @@ -415,6 +416,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 c5aed7b36f06421524737f5b8addb10eff6c4bc3 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 10:53:03 -0800 Subject: [PATCH 16/31] 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 92039102..46cd61d7 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 81eb21f4..bc229810 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 43678329..ed7ecf1c 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 bfdd84b270a9c8c60cc4301813b5f9abc0ca5b9a Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 11:52:22 -0800 Subject: [PATCH 17/31] 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 882e9695..c288e349 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`: The @@ -539,17 +532,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. @@ -588,15 +581,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 05afca93..a69563f1 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 "==" @@ -201,8 +208,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, @@ -416,8 +423,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 fa7b8a7c4b85ede45f4096459192d68817577a1e Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 16:10:26 -0800 Subject: [PATCH 18/31] 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 46cd61d7..7f8da974 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 bc229810..0b9ee51d 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 a69563f1..7d178187 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}") @@ -129,7 +129,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: @@ -248,7 +248,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()): @@ -343,7 +343,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: @@ -469,7 +469,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 ed7ecf1c..279d2842 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 e34888c888841b7480a0d0f67f924558adac7be8 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 4 Feb 2026 16:46:18 -0800 Subject: [PATCH 19/31] 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 7f8da974..cdc53753 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 c288e349..d30538ba 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 @@ -518,8 +519,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: @@ -536,13 +537,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. @@ -584,7 +585,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 0b9ee51d..80697c70 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 7d178187..02239b08 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 @@ -207,10 +203,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"], @@ -421,11 +416,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 a46b97d9..f7345703 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -760,7 +760,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 @@ -1942,7 +1942,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 @@ -1971,6 +1971,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 From a5df618319b4241dfd66563bc38d0b4c6e70b28f Mon Sep 17 00:00:00 2001 From: fastbodin Date: Thu, 5 Feb 2026 16:01:36 -0800 Subject: [PATCH 20/31] Address 2nd rnd. comments NumberNode axis-wise bounds Added indicator variable that all bound axis operators are `==` to reduce redundancy in `NumberNode::exchange()` method. --- .../dwave-optimization/nodes/numbers.hpp | 20 ++-- dwave/optimization/libcpp/nodes/numbers.pxd | 2 +- dwave/optimization/model.py | 8 +- dwave/optimization/src/nodes/numbers.cpp | 93 +++++++++++-------- dwave/optimization/symbols/numbers.pyx | 8 +- tests/cpp/nodes/test_numbers.cpp | 88 +++++++++++++----- 6 files changed, 138 insertions(+), 81 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index cdc53753..d434d975 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -28,10 +28,10 @@ namespace dwave::optimization { /// A contiguous block of numbers. class NumberNode : public ArrayOutputMixin, public DecisionNode { public: - /// 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 `Operator`. + /// 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 `Operator`. struct AxisBound { /// Allowable axis-wise bound operators. enum class Operator { Equal, LessEqual, GreaterEqual }; @@ -43,11 +43,11 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// 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). + /// 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 bounds PER slice - /// (length of vector is equal to the number of slices). + /// 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; /// Obtain the bound associated with a given slice along `axis`. @@ -143,7 +143,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// Return the stateless axis-wise bound information i.e. bound_axes_info_. const std::vector& axis_wise_bounds() const; - /// Return the state-dependent sum of the values within each hyperslice + /// Return the state-dependent sum of the values within each slice /// 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; @@ -173,6 +173,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// Stateless information on each bound axis. std::vector bound_axes_info_; + /// Indicator variable that all axis-wise bound operators are "==". + bool bound_axis_ops_all_equals_; }; /// A contiguous block of integer numbers. diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index 78d8d61d..375276ee 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -29,7 +29,7 @@ cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimi LessEqual GreaterEqual - AxisBound(Py_ssize_t axis, vector[Operator] axis_opertors, + AxisBound(Py_ssize_t axis, vector[Operator] axis_operators, vector[double] axis_bounds) Py_ssize_t axis vector[Operator] operators diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index d30538ba..8ce6a5f4 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -166,8 +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 | list[tuple(int, str | list[str], float | - list[float])] = 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: @@ -519,8 +519,8 @@ def integer( shape: None | _ShapeLike = None, lower_bound: None | numpy.typing.ArrayLike = None, upper_bound: None | numpy.typing.ArrayLike = None, - subject_to: None | list[tuple(int, str | list[str], float | - list[float])] = 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: diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 80697c70..4df43fa7 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -39,8 +39,8 @@ NumberNode::AxisBound::AxisBound(ssize_t bound_axis, std::vector axis_ throw std::invalid_argument("Axis-wise `operators` and `bounds` must have non-zero size."); } - // If `operators` and `bounds` are both defined PER hyperslice along - // `axis`, they must have the same size. + // If `operators` and `bounds` are both defined PER slice along `axis`, + // they must have the same size. if ((num_operators > 1) && (num_bounds > 1) && (num_bounds != num_operators)) { throw std::invalid_argument( "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); @@ -70,10 +70,10 @@ struct NumberNodeStateData : public ArrayNodeStateData { : 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" + /// For each bound axis and for each slice along said axis, we track the + /// sum of the values within the slice. + /// bound_axes_sums[i][j] = "sum of the values within the jth slice 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() @@ -94,7 +94,7 @@ 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. +/// slice along each bound axis. std::vector> get_bound_axes_sums(const NumberNode* node, const std::vector& number_data) { std::span node_shape = node->shape(); @@ -105,13 +105,13 @@ std::vector> get_bound_axes_sums(const NumberNode* node, static_cast(number_data.size())); // 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". + // of it's slice to 0. Define bound_axes_sums[i][j] = "sum of the values + // within the jth slice along the ith bound axis". std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); 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 + // Emplace an all zeros vector of size equal to the number of slice // along the given bound axis (axis_info.axis). bound_axes_sums.emplace_back(node_shape[axis_info.axis], 0.0); } @@ -120,7 +120,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 sum of the appropriate hyperslice along each bound axis. + // Increment the sum of the appropriate slice 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())); @@ -133,8 +133,8 @@ 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. +/// Determine whether the sum of the values within each slice 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()); @@ -178,8 +178,8 @@ 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)); } else { - // Given the assingnment to NumberNode `number_data`, compute the sum of the - // values within each hyperslice along each bound axis. + // Given the assingnment to NumberNode `number_data`, compute the sum + // of the values within each slice 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)) { @@ -252,7 +252,7 @@ double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, /// 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 +/// B) Incremements the values within each slice 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) { @@ -263,8 +263,8 @@ 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. To improve - // performance, compute sum during previous loop. + // 2) Determine the slice 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. @@ -272,22 +272,22 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, 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 traverses each hyperslice one - // 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. + // We need a way to iterate over each slice 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 slice one 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); // Define an iterator for `values` corresponding with the beginning of // slice 0 along the bound axis. const BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), buff_strides.data()); - // Determine the size of each hyperslice along the bound axis. + // Determine the size of each slice 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 + // 3) Iterate over each slice 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 needed to adjust the values within the slice. @@ -297,8 +297,8 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, 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_it = slice_0_it + offset, slice_end_it = slice_it + slice_size; @@ -367,12 +367,15 @@ 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 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); - // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - update_bound_axis_slice_sums(state, j, -difference); + // No need to update slice sums as they will be unchanged. + if (!bound_axis_ops_all_equals_) { + // If exchange occurred, 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(bound_axes_info_, ptr->bound_axes_sums)); } } @@ -419,9 +422,9 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c auto ptr = data_ptr(state); value = std::clamp(value, lower_bound(index), upper_bound(index)); // assert() that i is a valid index occurs in ptr->set(). - // State change occurs IFF `value` != buffer[index] . + // State change occurs IFF `value` != buffer[index]. if (ptr->set(index, value)) { - // If change occured, update bound axis sums by differnce. + // If change occurred, update bound axis sums by difference. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } @@ -447,6 +450,16 @@ double get_extreme_index_wise_bound(const std::vector& bound) { return *it; } +bool all_bound_axis_operators_are_equals(std::vector& bound_axes_info) { + for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { + for (const NumberNode::AxisBound::Operator op : bound_axis_info.operators) { + if (op != NumberNode::AxisBound::Operator::Equal) return false; + } + } + // Vacuously true if there are no axis-wise bounds. + return true; +} + void check_index_wise_bounds(const NumberNode& node, const std::vector& lower_bounds_, const std::vector& upper_bounds_) { bool index_wise_bound = false; @@ -534,7 +547,8 @@ 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_(std::move(bound_axes)) { + bound_axes_info_(std::move(bound_axes)), + bound_axis_ops_all_equals_(all_bound_axis_operators_are_equals(bound_axes_info_)) { if ((shape.size() > 0) && (shape[0] < 0)) { throw std::invalid_argument("Number array cannot have dynamic size."); } @@ -549,14 +563,15 @@ NumberNode::NumberNode(std::span shape, std::vector lower void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, const double value_change) const { + assert(value_change != 0); // Should not call when no change occurs. const auto& bound_axes_info = bound_axes_info_; - if (bound_axes_info.size() == 0) return; // No axis-wise bounds to satisfy + if (bound_axes_info.size() == 0) return; // No axis-wise bounds to satisfy. // 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. + // Get the slice sums of all bound axes. auto& bound_axes_sums = data_ptr(state)->bound_axes_sums; assert(bound_axes_info.size() == bound_axes_sums.size()); @@ -676,7 +691,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. + // If change occurred, update bound axis sums by difference. update_bound_axis_slice_sums(state, index, value - diff(state).back().old); assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); } diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 02239b08..dc248494 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -51,7 +51,7 @@ cdef NumberNode.AxisBound.Operator _parse_python_operator(str op) except *: # Convert the user-defined axis-wise bounds for NumberNode into the # corresponding C++ objects passed to NumberNode. cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( - bound_axes_data : None | list[tuple(int, str | list[str], float | list[float])]) except *: + bound_axes_data : None | list[tuple[int, str | list[str], float | list[float]]]) except *: cdef vector[NumberNode.AxisBound] output if bound_axes_data is None: @@ -82,7 +82,7 @@ cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( 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" + raise TypeError("Bound axis operator(s) should be str or an iterable" " of str(s).") bound_array = np.asarray_chkfinite(py_bounds, dtype=np.double) @@ -118,7 +118,7 @@ cdef class BinaryVariable(ArraySymbol): usage of this symbol. """ def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, - subject_to=None): + subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = None): cdef vector[Py_ssize_t] cppshape = as_cppshape( tuple() if shape is None else shape ) @@ -331,7 +331,7 @@ cdef class IntegerVariable(ArraySymbol): usage of this symbol. """ def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, - subject_to=None): + subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = None): cdef vector[Py_ssize_t] cppshape = as_cppshape( tuple() if shape is None else shape ) diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 279d2842..231c3515 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -579,9 +579,9 @@ 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}}}; - // 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. + // Each slice 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. REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2, 2}, std::nullopt, std::nullopt, bound_axes), "Infeasible axis-wise bounds."); @@ -590,9 +590,9 @@ 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}}}; - // 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. + // Each slice 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."); @@ -601,9 +601,9 @@ 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}}}; - // 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. + // Each slice 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."); @@ -765,7 +765,7 @@ TEST_CASE("BinaryNode") { WHEN("We initialize three invalid states") { auto state = graph.empty_state(); - // This state violates the 0th hyperslice along axis 0 + // This state violates slice 0 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.asarray([1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1]) @@ -776,7 +776,7 @@ TEST_CASE("BinaryNode") { "Initialized values do not satisfy axis-wise bounds."); state = graph.empty_state(); - // This state violates the 1st hyperslice along axis 0 + // This state violates the slice 1 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]) @@ -787,7 +787,7 @@ TEST_CASE("BinaryNode") { "Initialized values do not satisfy axis-wise bounds."); state = graph.empty_state(); - // This state violates the 2nd hyperslice along axis 0 + // This state violates the slice 2 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]) @@ -1381,9 +1381,9 @@ 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}}}; - // 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. + // Each slice 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."); @@ -1392,9 +1392,9 @@ 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}}}; - // 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. + // Each slice 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."); @@ -1403,9 +1403,9 @@ 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}}}; - // 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 + // Each slice 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."); @@ -1566,7 +1566,7 @@ TEST_CASE("IntegerNode") { WHEN("We initialize three invalid states") { auto state = graph.empty_state(); - // This state violates the 0th hyperslice along axis 1 + // This state violates the slice 0 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]) @@ -1577,7 +1577,7 @@ TEST_CASE("IntegerNode") { "Initialized values do not satisfy axis-wise bounds."); state = graph.empty_state(); - // This state violates the 1st hyperslice along axis 1 + // This state violates the slice 1 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]) @@ -1588,7 +1588,7 @@ TEST_CASE("IntegerNode") { "Initialized values do not satisfy axis-wise bounds."); state = graph.empty_state(); - // This state violates the 2nd hyperslice along axis 1 + // This state violates the slice 2 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]) @@ -1721,6 +1721,46 @@ TEST_CASE("IntegerNode") { } } } + + GIVEN("(2x3)-IntegerNode and an axis-wise bound on axis: 0 with operator `==`") { + auto graph = Graph(); + std::vector bound_axes{{0, {Equal}, {1.0}}}; + auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3}, + std::nullopt, std::nullopt, bound_axes); + + THEN("Axis wise bound is correct") { + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + 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)); + } + + WHEN("We initialize a valid state by construction") { + auto state = graph.empty_state(); + graph.initialize_state(state); + + auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(inode_ptr->bound_axis_sums(state).size() == 1); + CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({1.0, 1.0})); + CHECK_THAT(inode_ptr->view(state), RangeEquals({1, 0, 0, 1, 0, 0})); + } + + THEN("We exchange() some values") { + inode_ptr->exchange(state, 0, 1); + inode_ptr->exchange(state, 3, 4); + + THEN("The bound axis sums and state updated correctly") { + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({1.0, 1.0})); + CHECK(inode_ptr->diff(state).size() == 4); // 2 updates per exchange + CHECK_THAT(inode_ptr->view(state), RangeEquals({0, 1, 0, 0, 1, 0})); + } + } + } + } } } // namespace dwave::optimization From f4257124f3c69d457be4cb8d91a9173ab71eac32 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 6 Feb 2026 13:36:26 -0800 Subject: [PATCH 21/31] Reformat AxisBound struct on NumberNode Made axis, operators, and bounds private members. Added axis(), num_bounds(), and num_operators() methods. Updated C++ code/tests, Python, and Cython to reflect this. --- .../dwave-optimization/nodes/numbers.hpp | 27 ++- dwave/optimization/libcpp/nodes/numbers.pxd | 9 +- dwave/optimization/src/nodes/numbers.cpp | 93 +++++---- dwave/optimization/symbols/numbers.pyx | 20 +- tests/cpp/nodes/test_numbers.cpp | 190 +++++++++++------- 5 files changed, 211 insertions(+), 128 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index d434d975..a91d72c8 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -33,6 +33,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// Constraints can be defined for ALL slices along `axis` or PER slice /// along `axis`. Allowable operators are defined by `Operator`. struct AxisBound { + public: /// Allowable axis-wise bound operators. enum class Operator { Equal, LessEqual, GreaterEqual }; @@ -41,20 +42,27 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { 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; - /// 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; + ssize_t axis() const { return 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 `axis`. Operator get_operator(const ssize_t slice) const; + + ssize_t num_bounds() const { return bounds_.size(); }; + + ssize_t num_operators() const { return operators_.size(); }; + + private: + /// 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_; + /// 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_; }; NumberNode() = delete; @@ -120,6 +128,9 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { return initialize_state(state, std::move(values)); } + /// @copydoc Node::propagate() + void propagate(State& state) const override; + // NumberNode methods ***************************************************** // In the given state, swap the value of index i with the value of index j. diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index 375276ee..e6952a59 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -31,9 +31,12 @@ cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimi AxisBound(Py_ssize_t axis, vector[Operator] axis_operators, vector[double] axis_bounds) - Py_ssize_t axis - vector[Operator] operators - vector[double] bounds + + Py_ssize_t axis() + double get_bound(Py_ssize_t slice) + Operator get_operator(Py_ssize_t slice) + Py_ssize_t num_bounds() + Py_ssize_t num_operators() void initialize_state(State&, vector[double]) except+ double lower_bound(Py_ssize_t index) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 4df43fa7..77722b6e 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -31,9 +31,11 @@ namespace dwave::optimization { 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(); + : 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(); if ((num_operators == 0) || (num_bounds == 0)) { throw std::invalid_argument("Axis-wise `operators` and `bounds` must have non-zero size."); @@ -49,16 +51,16 @@ NumberNode::AxisBound::AxisBound(ssize_t bound_axis, std::vector axis_ 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]; + if (bounds_.size() == 1) return bounds_[0]; + assert(slice < static_cast(bounds_.size())); + return bounds_[slice]; } 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())); - return operators[slice]; + if (operators_.size() == 1) return operators_[0]; + assert(slice < static_cast(operators_.size())); + return operators_[slice]; } /// State dependant data attached to NumberNode @@ -110,10 +112,10 @@ std::vector> get_bound_axes_sums(const NumberNode* node, std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); for (const NumberNode::AxisBound& axis_info : bound_axes_info) { - assert(0 <= axis_info.axis && axis_info.axis < static_cast(node_shape.size())); + 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 slice // along the given bound axis (axis_info.axis). - bound_axes_sums.emplace_back(node_shape[axis_info.axis], 0.0); + bound_axes_sums.emplace_back(node_shape[axis_info.axis()], 0.0); } // Define a BufferIterator for `number_data` given the shape and strides of @@ -122,7 +124,7 @@ std::vector> get_bound_axes_sums(const NumberNode* node, it != std::default_sentinel; ++it) { // Increment the sum of the appropriate slice 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; + const ssize_t axis = bound_axes_info[bound_axis].axis(); assert(0 <= axis && axis < static_cast(it.location().size())); const ssize_t slice = it.location()[axis]; assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); @@ -157,6 +159,7 @@ bool satisfies_axis_wise_bounds(const std::vector& bound_ if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; break; default: + assert(false && "Unexpected operator type."); unreachable(); } } @@ -245,6 +248,7 @@ double compute_bound_axis_slice_delta(const ssize_t slice, const double sum, // Otherwise, sum satisfies bound. return (sum < bound) ? (bound - sum) : 0.0; default: + assert(false && "Unexpected operator type."); unreachable(); } } @@ -269,7 +273,7 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, const std::vector bound_axis_sums = get_bound_axes_sums(node, values).front(); // Obtain the stateless bound axis data for node. const NumberNode::AxisBound& bound_axis_info = node->axis_wise_bounds().front(); - const ssize_t bound_axis = bound_axis_info.axis; + const ssize_t bound_axis = bound_axis_info.axis(); assert(0 <= bound_axis && bound_axis < ndim); // We need a way to iterate over each slice along the bound axis and adjust @@ -339,10 +343,20 @@ void NumberNode::initialize_state(State& state) const { construct_state_given_exactly_one_bound_axis(this, values); initialize_state(state, std::move(values)); } else { + assert(false && "Multiple axis-wise bound not yet supported."); unreachable(); } } +void NumberNode::propagate(State& state) const { + // Should only propagate states that obey the axis-wise bounds. + assert(satisfies_axis_wise_bounds(bound_axes_info_, bound_axis_sums(state))); + // Technically vestigial but will keep it for forms sake. + for (const auto& sv : successors()) { + sv->update(state, sv.index); + } +} + void NumberNode::commit(State& state) const noexcept { auto node_data = data_ptr(state); // Manually store a copy of bound_axes_sums. @@ -367,16 +381,15 @@ 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)) { - // No need to update slice sums as they will be unchanged. - if (!bound_axis_ops_all_equals_) { - // If exchange occurred, update the bound axis sums. + // If change occurred and axis-wise bounds exist, update bound axis sums. + // Nothing to update if all axis bound operators are Equals. + if (!bound_axis_ops_all_equals_ && bound_axes_info_.size() > 0) { 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(bound_axes_info_, ptr->bound_axes_sums)); } } @@ -424,9 +437,10 @@ 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 occurred, update bound axis sums by difference. - update_bound_axis_slice_sums(state, index, value - diff(state).back().old); - assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); + // If change occurred and axis-wise bounds exist, update bound axis sums. + if (bound_axes_info_.size() > 0) { + update_bound_axis_slice_sums(state, index, value - diff(state).back().old); + } } } @@ -452,7 +466,8 @@ double get_extreme_index_wise_bound(const std::vector& bound) { bool all_bound_axis_operators_are_equals(std::vector& bound_axes_info) { for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - for (const NumberNode::AxisBound::Operator op : bound_axis_info.operators) { + for (ssize_t i = 0, stop = bound_axis_info.num_operators(); i < stop; ++i) { + const NumberNode::AxisBound::Operator op = bound_axis_info.get_operator(i); if (op != NumberNode::AxisBound::Operator::Equal) return false; } } @@ -499,19 +514,19 @@ void check_axis_wise_bounds(const NumberNode* node) { // For each set of bound axis data for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - const ssize_t axis = bound_axis_info.axis; + const ssize_t axis = bound_axis_info.axis(); if (axis < 0 || axis >= static_cast(shape.size())) { throw std::invalid_argument("Invalid bound axis given number array shape."); } - const ssize_t num_operators = static_cast(bound_axis_info.operators.size()); + const ssize_t num_operators = static_cast(bound_axis_info.num_operators()); if ((num_operators > 1) && (num_operators != shape[axis])) { throw std::invalid_argument( "Invalid number of axis-wise operators given number array shape."); } - const ssize_t num_bounds = static_cast(bound_axis_info.bounds.size()); + const ssize_t num_bounds = static_cast(bound_axis_info.num_bounds()); if ((num_bounds > 1) && (num_bounds != shape[axis])) { throw std::invalid_argument( "Invalid number of axis-wise bounds given number array shape."); @@ -563,9 +578,9 @@ NumberNode::NumberNode(std::span shape, std::vector lower void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, const double value_change) const { - assert(value_change != 0); // Should not call when no change occurs. const auto& bound_axes_info = bound_axes_info_; - if (bound_axes_info.size() == 0) return; // No axis-wise bounds to satisfy. + assert(value_change != 0); // Should not call when no change occurs. + assert(bound_axes_info.size() != 0); // Should only call where applicable. // Get multidimensional indices for `index` so we can identify the slices // `index` lies on per bound axis. @@ -578,10 +593,10 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, // 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())); + 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]; + 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 sum in slice. bound_axes_sums[bound_axis][slice] += value_change; @@ -595,7 +610,8 @@ void check_bound_axes_integrality(const std::vector& boun if (bound_axes_info.size() == 0) return; // No bound axes to check. for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - for (const double& bound : bound_axis_info.bounds) { + for (ssize_t i = 0, stop = bound_axis_info.num_bounds(); i < stop; ++i) { + const double bound = bound_axis_info.get_bound(i); if (bound != std::floor(bound)) { throw std::invalid_argument( "Axis wise bounds for integral number arrays must be intregral."); @@ -691,9 +707,10 @@ 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 occurred, update bound axis sums by difference. - update_bound_axis_slice_sums(state, index, value - diff(state).back().old); - assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); + // If change occurred and axis-wise bounds exist, update bound axis sums. + if (bound_axes_info_.size() > 0) { + update_bound_axis_slice_sums(state, index, value - diff(state).back().old); + } } } @@ -801,10 +818,12 @@ void BinaryNode::flip(State& state, ssize_t i) const { // 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. - update_bound_axis_slice_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); - assert(satisfies_axis_wise_bounds(bound_axes_info_, ptr->bound_axes_sums)); + // If change occurred and axis-wise bounds exist, update bound axis sums. + if (bound_axes_info_.size() > 0) { + // If value changed from 0 -> 1, update by 1. + // If value changed from 1 -> 0, update by -1. + update_bound_axis_slice_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); + } } } diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index dc248494..1d0f3551 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -248,11 +248,11 @@ cdef class BinaryVariable(ArraySymbol): 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)) + py_axis_ops = [_parse_cpp_operators(bound_axis.get_operator(j)) + for j in range(bound_axis.num_operators())] + py_axis_bounds = [bound_axis.get_bound(j) + for j in range(bound_axis.num_bounds())] + output.append((bound_axis.axis(), py_axis_ops, py_axis_bounds)) return output @@ -467,11 +467,11 @@ cdef class IntegerVariable(ArraySymbol): 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)) + py_axis_ops = [_parse_cpp_operators(bound_axis.get_operator(j)) + for j in range(bound_axis.num_operators())] + py_axis_bounds = [bound_axis.get_bound(j) + for j in range(bound_axis.num_bounds())] + output.append((bound_axis.axis(), py_axis_ops, py_axis_bounds)) return output diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 231c3515..853d8b2b 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -50,38 +50,46 @@ TEST_CASE("AxisBound") { } GIVEN("AxisBound(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { - std::vector operators{Equal, LessEqual, GreaterEqual}; - std::vector bounds{1.0}; AxisBound 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)); + CHECK(bound_axis.axis() == 2); + CHECK(bound_axis.num_bounds() == 1); + CHECK(bound_axis.get_bound(0) == 1.0); + CHECK(bound_axis.num_operators() == 3); + CHECK(bound_axis.get_operator(0) == Equal); + CHECK(bound_axis.get_operator(1) == LessEqual); + CHECK(bound_axis.get_operator(2) == GreaterEqual); } } 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}; - AxisBound bound_axis(2, operators, bounds); + AxisBound bound_axis(2, {Equal}, {1.0, 2.0, 3.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)); + CHECK(bound_axis.axis() == 2); + CHECK(bound_axis.num_bounds() == 3); + CHECK(bound_axis.get_bound(0) == 1.0); + CHECK(bound_axis.get_bound(1) == 2.0); + CHECK(bound_axis.get_bound(2) == 3.0); + CHECK(bound_axis.num_operators() == 1); + CHECK(bound_axis.get_operator(0) == Equal); } } 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}; - AxisBound bound_axis(2, operators, bounds); + AxisBound bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.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)); + CHECK(bound_axis.axis() == 2); + CHECK(bound_axis.num_bounds() == 3); + CHECK(bound_axis.get_bound(0) == 1.0); + CHECK(bound_axis.get_bound(1) == 2.0); + CHECK(bound_axis.get_bound(2) == 3.0); + CHECK(bound_axis.num_operators() == 3); + CHECK(bound_axis.get_operator(0) == Equal); + CHECK(bound_axis.get_operator(1) == LessEqual); + CHECK(bound_axis.get_operator(2) == GreaterEqual); } } } @@ -620,9 +628,15 @@ TEST_CASE("BinaryNode") { THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); 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)); + CHECK(bnode_bound_axis.axis() == 0); + CHECK(bnode_bound_axis.num_bounds() == 3); + CHECK(bnode_bound_axis.get_bound(0) == 1.0); + CHECK(bnode_bound_axis.get_bound(1) == 2.0); + CHECK(bnode_bound_axis.get_bound(2) == 3.0); + CHECK(bnode_bound_axis.num_operators() == 3); + CHECK(bnode_bound_axis.get_operator(0) == Equal); + CHECK(bnode_bound_axis.get_operator(1) == LessEqual); + CHECK(bnode_bound_axis.get_operator(2) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -665,9 +679,13 @@ TEST_CASE("BinaryNode") { THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); 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)); + CHECK(bnode_bound_axis.axis() == 1); + CHECK(bnode_bound_axis.num_bounds() == 2); + CHECK(bnode_bound_axis.get_bound(0) == 1.0); + CHECK(bnode_bound_axis.get_bound(1) == 5.0); + CHECK(bnode_bound_axis.num_operators() == 2); + CHECK(bnode_bound_axis.get_operator(0) == LessEqual); + CHECK(bnode_bound_axis.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -709,9 +727,13 @@ TEST_CASE("BinaryNode") { THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); 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)); + CHECK(bnode_bound_axis.axis() == 2); + CHECK(bnode_bound_axis.num_bounds() == 2); + CHECK(bnode_bound_axis.get_bound(0) == 3.0); + CHECK(bnode_bound_axis.get_bound(1) == 6.0); + CHECK(bnode_bound_axis.num_operators() == 2); + CHECK(bnode_bound_axis.get_operator(0) == Equal); + CHECK(bnode_bound_axis.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -751,9 +773,15 @@ TEST_CASE("BinaryNode") { THEN("Axis wise bound is correct") { CHECK(bnode_ptr->axis_wise_bounds().size() == 1); 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)); + CHECK(bnode_bound_axis.axis() == 0); + CHECK(bnode_bound_axis.num_bounds() == 3); + CHECK(bnode_bound_axis.get_bound(0) == 1.0); + CHECK(bnode_bound_axis.get_bound(1) == 2.0); + CHECK(bnode_bound_axis.get_bound(2) == 3.0); + CHECK(bnode_bound_axis.num_operators() == 3); + CHECK(bnode_bound_axis.get_operator(0) == Equal); + CHECK(bnode_bound_axis.get_operator(1) == LessEqual); + CHECK(bnode_bound_axis.get_operator(2) == GreaterEqual); } WHEN("We create a state using a random number generator") { @@ -1414,15 +1442,19 @@ 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}}}; - auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + auto inode_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); - 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)); + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == 0); + CHECK(inode_bound_axis.num_bounds() == 2); + CHECK(inode_bound_axis.get_bound(0) == -21.0); + CHECK(inode_bound_axis.get_bound(1) == 9.0); + CHECK(inode_bound_axis.num_operators() == 2); + CHECK(inode_bound_axis.get_operator(0) == Equal); + CHECK(inode_bound_axis.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -1443,13 +1475,13 @@ TEST_CASE("IntegerNode") { // 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); + auto bound_axis_sums = inode_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)); + CHECK(inode_ptr->bound_axis_sums(state).size() == 1); + CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({-21.0, 9.0})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } @@ -1457,15 +1489,21 @@ 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}}}; - auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + auto inode_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); - 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)); + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == 1); + CHECK(inode_bound_axis.num_bounds() == 3); + CHECK(inode_bound_axis.get_bound(0) == 0.0); + CHECK(inode_bound_axis.get_bound(1) == -2.0); + CHECK(inode_bound_axis.get_bound(2) == 0.0); + CHECK(inode_bound_axis.num_operators() == 3); + CHECK(inode_bound_axis.get_operator(0) == Equal); + CHECK(inode_bound_axis.get_operator(1) == GreaterEqual); + CHECK(inode_bound_axis.get_operator(2) == LessEqual); } WHEN("We create a state by initialize_state()") { @@ -1489,13 +1527,13 @@ TEST_CASE("IntegerNode") { // [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); + auto bound_axis_sums = inode_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)); + 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({0.0, -2.0, -20.0})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } @@ -1503,15 +1541,19 @@ 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}}}; - auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, + auto inode_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); - 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)); + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == 2); + CHECK(inode_bound_axis.num_bounds() == 2); + CHECK(inode_bound_axis.get_bound(0) == 23.0); + CHECK(inode_bound_axis.get_bound(1) == 14.0); + CHECK(inode_bound_axis.num_operators() == 2); + CHECK(inode_bound_axis.get_operator(0) == Equal); + CHECK(inode_bound_axis.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -1532,13 +1574,13 @@ TEST_CASE("IntegerNode") { // 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); + auto bound_axis_sums = inode_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)); + CHECK(inode_ptr->bound_axis_sums(state).size() == 1); + CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({23.0, 14.0})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } @@ -1551,10 +1593,16 @@ TEST_CASE("IntegerNode") { THEN("Axis wise bound is correct") { CHECK(inode_ptr->axis_wise_bounds().size() == 1); - 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)); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == 1); + CHECK(inode_bound_axis.num_bounds() == 3); + CHECK(inode_bound_axis.get_bound(0) == 11.0); + CHECK(inode_bound_axis.get_bound(1) == 2.0); + CHECK(inode_bound_axis.get_bound(2) == 5.0); + CHECK(inode_bound_axis.num_operators() == 3); + CHECK(inode_bound_axis.get_operator(0) == Equal); + CHECK(inode_bound_axis.get_operator(1) == LessEqual); + CHECK(inode_bound_axis.get_operator(2) == GreaterEqual); } WHEN("We create a state using a random number generator") { @@ -1730,10 +1778,12 @@ TEST_CASE("IntegerNode") { THEN("Axis wise bound is correct") { CHECK(inode_ptr->axis_wise_bounds().size() == 1); - 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)); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == 0); + CHECK(inode_bound_axis.num_bounds() == 1); + CHECK(inode_bound_axis.get_bound(0) == 1.0); + CHECK(inode_bound_axis.num_operators() == 1); + CHECK(inode_bound_axis.get_operator(0) == Equal); } WHEN("We initialize a valid state by construction") { From 4aefca9f2dce224983a79fdda768bc34713885a7 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 11 Feb 2026 09:48:54 -0800 Subject: [PATCH 22/31] Modified arg types for `NumberNode::bound_axis_sums() Added a `const` to `State& state`. Fixed typos. --- .../optimization/include/dwave-optimization/nodes/numbers.hpp | 2 +- dwave/optimization/src/nodes/numbers.cpp | 4 ++-- dwave/optimization/symbols/numbers.pyx | 2 +- tests/cpp/nodes/test_numbers.cpp | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index a91d72c8..f9cfa253 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -157,7 +157,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// Return the state-dependent sum of the values within each slice /// 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; + const std::vector>& bound_axis_sums(const 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 77722b6e..fde46cce 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -448,7 +448,7 @@ const std::vector& NumberNode::axis_wise_bounds() const { return bound_axes_info_; } -const std::vector>& NumberNode::bound_axis_sums(State& state) const { +const std::vector>& NumberNode::bound_axis_sums(const State& state) const { return data_ptr(state)->bound_axes_sums; } @@ -614,7 +614,7 @@ void check_bound_axes_integrality(const std::vector& boun const double bound = bound_axis_info.get_bound(i); if (bound != std::floor(bound)) { throw std::invalid_argument( - "Axis wise bounds for integral number arrays must be intregral."); + "Axis wise bounds for integral number arrays must be integral."); } } } diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 1d0f3551..51fa117b 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -338,7 +338,7 @@ cdef class IntegerVariable(ArraySymbol): cdef optional[vector[double]] cpplower_bound = nullopt cdef optional[vector[double]] cppupper_bound = nullopt - cdef vector[BinaryNode.AxisBound] cppbound_axes = _convert_python_bound_axes(subject_to) + cdef vector[IntegerNode.AxisBound] cppbound_axes = _convert_python_bound_axes(subject_to) cdef const double[:] mem if lower_bound is not None: diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 853d8b2b..e1a8a6af 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -581,7 +581,7 @@ TEST_CASE("BinaryNode") { 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."); + "Axis wise bounds for integral number arrays must be integral."); } GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { @@ -1403,7 +1403,7 @@ TEST_CASE("IntegerNode") { 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."); + "Axis wise bounds for integral number arrays must be integral."); } GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { From 812d3f21e6c095f3c226a41e817fa2af09fbcbbc Mon Sep 17 00:00:00 2001 From: fastbodin Date: Thu, 12 Feb 2026 15:10:11 -0800 Subject: [PATCH 23/31] Override copy method to NumberNodeStateData --- dwave/optimization/src/nodes/numbers.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index fde46cce..326906fc 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -72,6 +72,11 @@ struct NumberNodeStateData : public ArrayNodeStateData { : ArrayNodeStateData(std::move(input)), bound_axes_sums(std::move(bound_axes_sums)), prior_bound_axes_sums(this->bound_axes_sums) {} + + std::unique_ptr copy() const override { + return std::make_unique(*this); + } + /// For each bound axis and for each slice along said axis, we track the /// sum of the values within the slice. /// bound_axes_sums[i][j] = "sum of the values within the jth slice along From a1e9c78c27943fb32b1aa840e148e9c97660c93f Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 27 Feb 2026 10:14:17 -0800 Subject: [PATCH 24/31] Allow bounds over entire `NumberNode` array at C++ level. Previously, users could not define a bound for the sum of the values over an entire `NumberNode` array. This lead to the undesired behaviour that users could not define a bound on the sum of the values in a `NumberNode` vector. --- .../dwave-optimization/nodes/numbers.hpp | 10 +- dwave/optimization/src/nodes/numbers.cpp | 132 ++++-- tests/cpp/nodes/test_numbers.cpp | 396 ++++++++++++++++++ 3 files changed, 502 insertions(+), 36 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index f9cfa253..172c92f0 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -30,6 +30,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { public: /// Struct for stateless axis-wise bound information. Given an `axis`, /// define constraints on the sum of the values in each slice along `axis`. + /// Should `axis` be undefined, the constraint applies to the entire dataset. /// Constraints can be defined for ALL slices along `axis` or PER slice /// along `axis`. Allowable operators are defined by `Operator`. struct AxisBound { @@ -39,10 +40,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// To reduce the # of `IntegerNode` and `BinaryNode` constructors, we /// allow only one constructor. - AxisBound(ssize_t axis, std::vector axis_operators, + AxisBound(std::optional axis, std::vector axis_operators, std::vector axis_bounds); - ssize_t axis() const { return axis_; }; + std::optional axis() const { return axis_; }; /// Obtain the bound associated with a given slice along `axis`. double get_bound(const ssize_t slice) const; @@ -55,8 +56,9 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { ssize_t num_operators() const { return operators_.size(); }; private: - /// The bound axis - ssize_t axis_; + /// The bound axis (should it be defined). If axis_=nullopt, bound + /// applies to entire dataset. + std::optional axis_ = std::nullopt; /// 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_; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 326906fc..59f84934 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -29,7 +29,8 @@ namespace dwave::optimization { -NumberNode::AxisBound::AxisBound(ssize_t bound_axis, std::vector axis_operators, +NumberNode::AxisBound::AxisBound(std::optional bound_axis, + std::vector axis_operators, std::vector axis_bounds) : axis_(bound_axis), operators_(std::move(axis_operators)), @@ -41,6 +42,11 @@ NumberNode::AxisBound::AxisBound(ssize_t bound_axis, std::vector axis_ throw std::invalid_argument("Axis-wise `operators` and `bounds` must have non-zero size."); } + if (!axis_.has_value() && (num_operators != 1 || num_bounds != 1)) { + throw std::invalid_argument( + "If `axis` is undefined, `operators` and `bounds` must have size 1."); + } + // If `operators` and `bounds` are both defined PER slice along `axis`, // they must have the same size. if ((num_operators > 1) && (num_bounds > 1) && (num_bounds != num_operators)) { @@ -81,7 +87,8 @@ struct NumberNodeStateData : public ArrayNodeStateData { /// sum of the values within the slice. /// bound_axes_sums[i][j] = "sum of the values within the jth slice along /// the ith bound axis" - /// Note that "ith bound axis" does not necessarily mean the ith axis. + /// Note 1) That "ith bound axis" does not necessarily mean the ith axis. + /// Note 2) If axis = nullopt, the entire array is considered the slice. std::vector> bound_axes_sums; // Store a copy for NumberNode::revert() and commit() std::vector> prior_bound_axes_sums; @@ -117,10 +124,17 @@ std::vector> get_bound_axes_sums(const NumberNode* node, std::vector> bound_axes_sums; bound_axes_sums.reserve(num_bound_axes); for (const NumberNode::AxisBound& axis_info : bound_axes_info) { - assert(0 <= axis_info.axis() && axis_info.axis() < static_cast(node_shape.size())); + const std::optional axis = axis_info.axis(); + // Handle the case where the bound applies to the entire array. + if (!axis.has_value()) { + bound_axes_sums.emplace_back(1, 0.0); + continue; + } + assert(axis.has_value()); + assert(0 <= *axis && *axis < static_cast(node_shape.size())); // Emplace an all zeros vector of size equal to the number of slice // along the given bound axis (axis_info.axis). - bound_axes_sums.emplace_back(node_shape[axis_info.axis()], 0.0); + bound_axes_sums.emplace_back(node_shape[*axis], 0.0); } // Define a BufferIterator for `number_data` given the shape and strides of @@ -129,9 +143,16 @@ std::vector> get_bound_axes_sums(const NumberNode* node, it != std::default_sentinel; ++it) { // Increment the sum of the appropriate slice 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())); - const ssize_t slice = it.location()[axis]; + const std::optional axis = bound_axes_info[bound_axis].axis(); + // Handle the case where the bound applies to the entire array. + if (!axis.has_value()) { + assert(bound_axes_sums[bound_axis].size() == 1); + bound_axes_sums[bound_axis].front() += *it; + continue; + } + assert(axis.has_value() && 0 <= *axis && + *axis < static_cast(it.location().size())); + const ssize_t slice = it.location()[*axis]; assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); bound_axes_sums[bound_axis][slice] += *it; } @@ -229,15 +250,14 @@ std::vector undo_shift_axis_data(const std::span span, c return output; } -/// Given a `slice` along a bound axis in a NumberNode where the sum of it's +/// 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::AxisBound::Operator op, +double compute_bound_axis_slice_delta(const double sum, const NumberNode::AxisBound::Operator op, const double bound) { switch (op) { case NumberNode::AxisBound::Operator::Equal: @@ -278,16 +298,40 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, const std::vector bound_axis_sums = get_bound_axes_sums(node, values).front(); // Obtain the stateless bound axis data for node. 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); + const std::optional bound_axis = bound_axis_info.axis(); + + // Handle the case where the bound applies to the entire array. + if (!bound_axis.has_value()) { + assert(bound_axis_sums.size() == 1); + // Determine the amount needed to adjust the values within the array. + double delta = compute_bound_axis_slice_delta(bound_axis_sums.front(), + bound_axis_info.get_operator(0), + bound_axis_info.get_bound(0)); + if (delta == 0) return; // Bound is satisfied for entire array. + + for (ssize_t i = 0, stop = node->size(); i < stop; ++i) { + // Determine allowable amount we can increment the value in at `i`. + const double inc = std::min(delta, node->upper_bound(i) - values[i]); + + if (inc > 0) { // Apply the increment to both `values` and `delta`. + values[i] += inc; + delta -= inc; + if (delta == 0) break; // Bound is satisfied for entire array. + } + } + + if (delta != 0) throw std::invalid_argument("Infeasible axis-wise bounds."); + return; + } + assert(bound_axis.has_value() && 0 <= *bound_axis && *bound_axis < ndim); // We need a way to iterate over each slice 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 slice one 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); + 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. const BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), @@ -298,9 +342,9 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, // 3) Iterate over each slice 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) { + for (ssize_t slice = 0, stop = node_shape[*bound_axis]; slice < stop; ++slice) { // Determine the amount needed to adjust the values within the slice. - double delta = compute_bound_axis_slice_delta(slice, bound_axis_sums[slice], + double delta = compute_bound_axis_slice_delta(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. @@ -314,11 +358,12 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, 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); + 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_it.location())); + assert(std::ranges::equal( + shift_axis_data(unravel_index(index, node_shape), *bound_axis), + 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); @@ -514,25 +559,40 @@ void check_axis_wise_bounds(const NumberNode* node) { 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. + // Used to assess if an axis have been bound multiple times. std::vector axis_bound(shape.size(), false); + // Used to assess if multiple bounds have been applied to the entire array. + bool array_bound = false; // For each set of bound axis data for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - const ssize_t axis = bound_axis_info.axis(); + const std::optional axis = bound_axis_info.axis(); + const ssize_t num_operators = static_cast(bound_axis_info.num_operators()); + const ssize_t num_bounds = static_cast(bound_axis_info.num_bounds()); + + // Handle the case where the bound applies to the entire array. + if (!axis.has_value()) { + // Checked in AxisBound constructor + assert(num_operators == 1 && num_bounds == 1); + + if (array_bound) + throw std::invalid_argument("Cannot define multiple bounds for the entire array."); + array_bound = true; + continue; + } + + assert(axis.has_value()); - if (axis < 0 || axis >= static_cast(shape.size())) { + if (*axis < 0 || *axis >= static_cast(shape.size())) { throw std::invalid_argument("Invalid bound axis given number array shape."); } - const ssize_t num_operators = static_cast(bound_axis_info.num_operators()); - if ((num_operators > 1) && (num_operators != shape[axis])) { + if ((num_operators > 1) && (num_operators != shape[*axis])) { throw std::invalid_argument( "Invalid number of axis-wise operators given number array shape."); } - const ssize_t num_bounds = static_cast(bound_axis_info.num_bounds()); - if ((num_bounds > 1) && (num_bounds != shape[axis])) { + if ((num_bounds > 1) && (num_bounds != shape[*axis])) { throw std::invalid_argument( "Invalid number of axis-wise bounds given number array shape."); } @@ -540,11 +600,11 @@ void check_axis_wise_bounds(const NumberNode* node) { // Checked in AxisBound constructor assert(num_operators == num_bounds || num_operators == 1 || num_bounds == 1); - if (axis_bound[axis]) { + if (axis_bound[*axis]) { throw std::invalid_argument( "Cannot define multiple axis-wise bounds for a single axis."); } - axis_bound[axis] = true; + axis_bound[*axis] = true; } // *Currently*, we only support axis-wise bounds for up to one axis. @@ -598,10 +658,18 @@ void NumberNode::update_bound_axis_slice_sums(State& state, const ssize_t index, // 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())); + const std::optional axis = bound_axes_info[bound_axis].axis(); + + // Handle the case where the bound applies to the entire array. + if (!axis.has_value()) { + assert(bound_axes_sums[bound_axis].size() == 1); + bound_axes_sums[bound_axis].front() += value_change; + continue; + } + + assert(axis.has_value() && 0 <= *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()]; + const ssize_t slice = multi_index[*axis]; assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); // Offset sum in slice. bound_axes_sums[bound_axis][slice] += value_change; diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index e1a8a6af..59b35671 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -33,6 +33,26 @@ using NumberNode::AxisBound::Operator::GreaterEqual; using NumberNode::AxisBound::Operator::LessEqual; TEST_CASE("AxisBound") { + GIVEN("AxisBound(axis = nullopt, operators = {}, bounds = {1.0})") { + REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {}, {1.0}), + "Axis-wise `operators` and `bounds` must have non-zero size."); + } + + GIVEN("AxisBound(axis = nullopt, operators = {<=}, bounds = {})") { + REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {LessEqual}, {}), + "Axis-wise `operators` and `bounds` must have non-zero size."); + } + + GIVEN("AxisBound(axis = nullopt, operators = {<=, ==}, bounds = {1.0})") { + REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {LessEqual, Equal}, {1.0}), + "If `axis` is undefined, `operators` and `bounds` must have size 1."); + } + + GIVEN("AxisBound(axis = nullopt, operators = {<=}, bounds = {1.0, 2.0})") { + REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {LessEqual}, {1.0, 2.0}), + "If `axis` is undefined, `operators` and `bounds` must have size 1."); + } + 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."); @@ -49,6 +69,18 @@ TEST_CASE("AxisBound") { "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); } + GIVEN("AxisBound(axis = nullopt, operators = {==}, bounds = {1.0})") { + AxisBound bound_axis(std::nullopt, {Equal}, {1.0}); + + THEN("The bound axis info is correct") { + CHECK(bound_axis.axis() == std::nullopt); + CHECK(bound_axis.num_bounds() == 1); + CHECK(bound_axis.get_bound(0) == 1.0); + CHECK(bound_axis.num_operators() == 1); + CHECK(bound_axis.get_operator(0) == Equal); + } + } + GIVEN("AxisBound(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { AxisBound bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0}); @@ -509,6 +541,7 @@ TEST_CASE("BinaryNode") { "Number array cannot have dynamic size."); } + // *********************** Axis-wise bounds tests ************************* GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { std::vector bound_axes{{-1, {Equal}, {1.0}}}; @@ -557,6 +590,15 @@ TEST_CASE("BinaryNode") { "Invalid number of axis-wise bounds given number array shape."); } + GIVEN("(6)-BinaryNode with duplicate bounds over the entire array") { + AxisBound bound_axis{std::nullopt, {Equal}, {1.0}}; + std::vector bound_axes{bound_axis, bound_axis}; + + REQUIRE_THROWS_WITH( + graph.emplace_node(6, std::nullopt, std::nullopt, bound_axes), + "Cannot define multiple bounds for the entire array."); + } + GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { AxisBound bound_axis{1, {Equal}, {1.0}}; std::vector bound_axes{bound_axis, bound_axis}; @@ -566,6 +608,16 @@ TEST_CASE("BinaryNode") { "Cannot define multiple axis-wise bounds for a single axis."); } + GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 0 and the entire array.") { + AxisBound bound_axis{std::nullopt, {LessEqual}, {1.0}}; + AxisBound bound_axis_1{1, {LessEqual}, {1.0}}; + std::vector bound_axes{bound_axis, bound_axis_1}; + + 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("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 1") { AxisBound bound_axis_0{0, {LessEqual}, {1.0}}; AxisBound bound_axis_1{1, {LessEqual}, {1.0}}; @@ -584,6 +636,30 @@ TEST_CASE("BinaryNode") { "Axis wise bounds for integral number arrays must be integral."); } + GIVEN("(6)-BinaryNode with an infeasible bound over the entire array.") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {Equal}, {7.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{6}, + std::nullopt, std::nullopt, bound_axes), + "Infeasible axis-wise bounds."); + } + + GIVEN("(3x2)-BinaryNode with an infeasible bound over the entire array.") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {GreaterEqual}, {7.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2}, + std::nullopt, std::nullopt, bound_axes), + "Infeasible axis-wise bounds."); + } + + GIVEN("(2x2x2)-BinaryNode with an infeasible bound over the entire array.") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {LessEqual}, {-1.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 2, 2}, + std::nullopt, std::nullopt, bound_axes), + "Infeasible axis-wise bounds."); + } + 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}}}; @@ -617,6 +693,38 @@ TEST_CASE("BinaryNode") { "Infeasible axis-wise bounds."); } + GIVEN("(6)-BinaryNode with a feasible bound over the entire array.") { + auto graph = Graph(); + std::vector lower_bounds{0, 0, 1, 0, 0, 1}; + std::vector upper_bounds{0, 1, 1, 1, 1, 1}; + std::vector bound_axes{{std::nullopt, {Equal}, {3.0}}}; + auto bnode_ptr = graph.emplace_node(6, lower_bounds, upper_bounds, bound_axes); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bnode_bound_axis.axis() == std::nullopt); + CHECK(bnode_bound_axis.num_bounds() == 1); + CHECK(bnode_bound_axis.get_bound(0) == 3.0); + CHECK(bnode_bound_axis.num_operators() == 1); + CHECK(bnode_bound_axis.get_operator(0) == Equal); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + std::vector expected_init{0, 1, 1, 0, 0, 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() == 1); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({3})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + 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}; @@ -764,6 +872,115 @@ TEST_CASE("BinaryNode") { } } + GIVEN("(2)-BinaryNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {Equal}, {1}}}; + auto bnode_ptr = graph.emplace_node(2, std::nullopt, std::nullopt, bound_axes); + + WHEN("We initialize an invalid states") { + auto state = graph.empty_state(); + std::vector init_values{0, 0}; + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + + WHEN("We initialize an invalid states") { + auto state = graph.empty_state(); + std::vector init_values{1, 1}; + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + } + + GIVEN("(2)-BinaryNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {GreaterEqual}, {1}}}; + auto bnode_ptr = graph.emplace_node(2, std::nullopt, std::nullopt, bound_axes); + + WHEN("We initialize an invalid states") { + auto state = graph.empty_state(); + std::vector init_values{0, 0}; + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + } + + GIVEN("(2)-BinaryNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {LessEqual}, {1}}}; + auto bnode_ptr = graph.emplace_node(2, std::nullopt, std::nullopt, bound_axes); + + WHEN("We initialize an invalid states") { + auto state = graph.empty_state(); + std::vector init_values{1, 1}; + CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + } + + GIVEN("(2x2x2)-BinaryNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {LessEqual}, {5}}}; + auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 2, 2}, + std::nullopt, std::nullopt, bound_axes); + + THEN("Axis wise bound is correct") { + CHECK(bnode_ptr->axis_wise_bounds().size() == 1); + AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; + CHECK(bnode_bound_axis.axis() == std::nullopt); + CHECK(bnode_bound_axis.num_bounds() == 1); + CHECK(bnode_bound_axis.get_bound(0) == 5.0); + CHECK(bnode_bound_axis.num_operators() == 1); + CHECK(bnode_bound_axis.get_operator(0) == LessEqual); + } + + 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 a valid state") { + auto state = graph.empty_state(); + std::vector init_values{0, 0, 0, 1, 1, 0, 0, 0}; + bnode_ptr->initialize_state(state, init_values); + graph.initialize_state(state); + + 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() == 1); + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({2.0})); + CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); + } + + THEN("We exchange() some values") { + bnode_ptr->exchange(state, 0, 1); // Does nothing. + bnode_ptr->exchange(state, 2, 3); + std::swap(init_values[0], init_values[1]); + std::swap(init_values[2], init_values[3]); + // state is now: [0, 0, 1, 0, 1, 0, 0, 0] + + THEN("The bound axis sums and state updated correctly") { + CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({2.0})); + 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({2.0})); + CHECK(bnode_ptr->diff(state).size() == 0); + } + } + } + } + } + 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}}}; @@ -1035,6 +1252,7 @@ TEST_CASE("BinaryNode") { } } } + // *********************** Axis-wise bounds tests ************************* } TEST_CASE("IntegerNode") { @@ -1334,6 +1552,7 @@ TEST_CASE("IntegerNode") { "Number array cannot have dynamic size."); } + // *********************** Axis-wise bounds tests ************************* GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { std::vector bound_axes{{-2, {Equal}, {20.0}}}; @@ -1382,6 +1601,15 @@ TEST_CASE("IntegerNode") { "Invalid number of axis-wise bounds given number array shape."); } + GIVEN("(6)-IntegerNode with duplicate bounds over the entire array") { + AxisBound bound_axis{std::nullopt, {Equal}, {10.0}}; + std::vector bound_axes{bound_axis, bound_axis}; + + REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{6}, + std::nullopt, std::nullopt, bound_axes), + "Cannot define multiple bounds for the entire array."); + } + GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { std::vector bound_axes{{1, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; @@ -1390,6 +1618,14 @@ TEST_CASE("IntegerNode") { "Cannot define multiple axis-wise bounds for a single axis."); } + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 and the entire array.") { + std::vector bound_axes{{std::nullopt, {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), + "Axis-wise bounds are supported for at most one axis."); + } + GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { std::vector bound_axes{{0, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; @@ -1406,6 +1642,27 @@ TEST_CASE("IntegerNode") { "Axis wise bounds for integral number arrays must be integral."); } + GIVEN("(6)-IntegerNode with an infeasible bound over the entire array.") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {Equal}, {-7.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 8, bound_axes), + "Infeasible axis-wise bounds."); + } + + GIVEN("(6)-IntegerNode with an infeasible bound over the entire array.") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {LessEqual}, {-7.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 8, bound_axes), + "Infeasible axis-wise bounds."); + } + + GIVEN("(6)-IntegerNode with an infeasible bound over the entire array.") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {GreaterEqual}, {13}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 2, bound_axes), + "Infeasible axis-wise bounds."); + } + 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}}}; @@ -1439,6 +1696,37 @@ TEST_CASE("IntegerNode") { "Infeasible axis-wise bounds."); } + GIVEN("(2x2x2)-IntegerNode with a feasible bound over the entire array ") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {GreaterEqual}, {40}}}; + auto inode_ptr = graph.emplace_node(std::initializer_list{2, 2, 2}, + -5, 8, bound_axes); + + THEN("Axis wise bound is correct") { + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == std::nullopt); + CHECK(inode_bound_axis.num_bounds() == 1); + CHECK(inode_bound_axis.get_bound(0) == 40.0); + CHECK(inode_bound_axis.num_operators() == 1); + CHECK(inode_bound_axis.get_operator(0) == GreaterEqual); + } + + WHEN("We create a state by initialize_state()") { + auto state = graph.initialize_state(); + graph.initialize_state(state); + std::vector expected_init{8, 8, 8, 8, 8, 8, -3, -5}; + auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + + THEN("The bound axis sums and state are correct") { + CHECK(inode_ptr->bound_axis_sums(state).size() == 1); + CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 1); + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({40})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); + } + } + } + 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}}}; @@ -1585,6 +1873,113 @@ TEST_CASE("IntegerNode") { } } + GIVEN("(2)-IntegerNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {Equal}, {15}}}; + auto inode_ptr = graph.emplace_node(2, -5, 8, bound_axes); + + WHEN("We initialize two invalid states") { + auto state = graph.empty_state(); + std::vector init_values{0.0, 0.0}; + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + + state = graph.empty_state(); + init_values = {8.0, 8.0}; + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + } + + GIVEN("(2)-IntegerNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {LessEqual}, {10}}}; + auto inode_ptr = graph.emplace_node(2, -5, 8, bound_axes); + + WHEN("We initialize an invalid states") { + auto state = graph.empty_state(); + std::vector init_values{8.0, 7.0}; + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + } + + GIVEN("(2)-IntegerNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {GreaterEqual}, {10}}}; + auto inode_ptr = graph.emplace_node(2, -5, 8, bound_axes); + + WHEN("We initialize an invalid states") { + auto state = graph.empty_state(); + std::vector init_values{-5.0, -4.0}; + CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), + "Initialized values do not satisfy axis-wise bounds."); + } + } + + GIVEN("(2x2)-BinaryNode with a bound over the entire array") { + auto graph = Graph(); + std::vector bound_axes{{std::nullopt, {GreaterEqual}, {5.0}}}; + auto inode_ptr = graph.emplace_node(std::initializer_list{2, 2}, -5, + 8, bound_axes); + + THEN("Axis wise bound is correct") { + CHECK(inode_ptr->axis_wise_bounds().size() == 1); + AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; + CHECK(inode_bound_axis.axis() == std::nullopt); + CHECK(inode_bound_axis.num_bounds() == 1); + CHECK(inode_bound_axis.get_bound(0) == 5.0); + CHECK(inode_bound_axis.num_operators() == 1); + CHECK(inode_bound_axis.get_operator(0) == GreaterEqual); + } + + 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 a valid state") { + auto state = graph.empty_state(); + std::vector init_values{1.0, -1.0, 0.0, 5.0}; + 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") { + CHECK(inode_ptr->bound_axis_sums(state).size() == 1); + CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 1); + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({5.0})); + CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); + } + + THEN("We set_value() some values") { + inode_ptr->set_value(state, 0, 1); // Does nothing. + inode_ptr->set_value(state, 2, 3); + init_values[0] = 1; + init_values[2] = 3; + // state is now: [1.0, -1.0, 3.0, 5.0] + + THEN("The bound axis sums and state updated correctly") { + CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({8.0})); + CHECK(inode_ptr->diff(state).size() == 1); + 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({5.0})); + CHECK(inode_ptr->diff(state).size() == 0); + } + } + } + } + } + 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}}}; @@ -1811,6 +2206,7 @@ TEST_CASE("IntegerNode") { } } } + // *********************** Axis-wise bounds tests ************************* } } // namespace dwave::optimization From 3a91b7b6effe3caa7c673467583af95455305831 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 27 Feb 2026 12:54:31 -0800 Subject: [PATCH 25/31] Allow bounds over entire `NumberNode` array at Python level. Users may now define a bound on the sum of the values in the entire `NumberNode` array. --- dwave/optimization/libcpp/nodes/numbers.pxd | 5 +- dwave/optimization/model.py | 41 +++++++++++--- dwave/optimization/symbols/numbers.pyx | 63 ++++++++++++++------- tests/test_symbols.py | 44 ++++++++++++++ 4 files changed, 124 insertions(+), 29 deletions(-) diff --git a/dwave/optimization/libcpp/nodes/numbers.pxd b/dwave/optimization/libcpp/nodes/numbers.pxd index e6952a59..4ed9ae2e 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from libcpp.optional cimport optional from libcpp.vector cimport vector from dwave.optimization.libcpp.graph cimport ArrayNode @@ -29,10 +30,10 @@ cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimi LessEqual GreaterEqual - AxisBound(Py_ssize_t axis, vector[Operator] axis_operators, + AxisBound(optional[Py_ssize_t] axis, vector[Operator] axis_operators, vector[double] axis_bounds) - Py_ssize_t axis() + optional[Py_ssize_t] axis() double get_bound(Py_ssize_t slice) Operator get_operator(Py_ssize_t slice) Py_ssize_t num_bounds() diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 8ce6a5f4..78402dac 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -166,8 +166,9 @@ 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 | list[tuple[int, str | list[str], float | - list[float]]] = None) -> BinaryVariable: + subject_to: None | list[tuple[int, str | list[str], float | list[float]] | + tuple[str | list[str], float | list[float]]] = None + ) -> BinaryVariable: r"""Create a binary symbol as a decision variable. Args: @@ -182,7 +183,9 @@ def binary(self, shape: None | _ShapeLike = None, [0,1]. If None, the default value of 1 is used. 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. + or (operators, bounds). + - axis (optional int): The axis along which the bounds are applied. If + not axis is provided, the bound will be applied to the entire array. - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). A single operator applies to all slices along the axis; an array specifies one operator per slice. @@ -239,7 +242,17 @@ def binary(self, shape: None | _ShapeLike = None, >>> model = Model() >>> 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])]) + >>> np.all(b.axis_wise_bounds() == [(1, ["<=", "==", ">="], [0, 2, 1])]) + True + + This example adds a :math:`6`-sized binary symbol such that + the sum of the values within the array is equal to 2. + + >>> from dwave.optimization.model import Model + >>> import numpy as np + >>> model = Model() + >>> b = model.binary(6, subject_to=[("==", 2)]) + >>> np.all(b.axis_wise_bounds() == [(["=="], [2])]) True See Also: @@ -519,8 +532,9 @@ def integer( shape: None | _ShapeLike = None, lower_bound: None | numpy.typing.ArrayLike = None, upper_bound: None | numpy.typing.ArrayLike = None, - subject_to: None | list[tuple[int, str | list[str], float | - list[float]]] = None) -> IntegerVariable: + subject_to: None | list[tuple[int, str | list[str], float | list[float]] | + tuple[str | list[str], float | list[float]]] = None + ) -> IntegerVariable: r"""Create an integer symbol as a decision variable. Args: @@ -535,7 +549,9 @@ def integer( default value is used. 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. + or (operators, bounds). + - axis (optional int): The axis along which the bounds are applied. If + not axis is provided, the bound will be applied to the entire array. - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). A single operator applies to all slice along the axis; an array specifies one operator per slice. @@ -596,6 +612,17 @@ def integer( >>> np.all(i.axis_wise_bounds() == [(1, ["<="], [2, 4, 5])]) True + This example adds a :math:`6`-sized integer symbol such that + the sum of the values within the array is less than or equal + to 20. + + >>> from dwave.optimization.model import Model + >>> import numpy as np + >>> model = Model() + >>> i = model.integer(6, subject_to=[("<=", 20)]) + >>> np.all(i.axis_wise_bounds() == [(["<="], [20])]) + 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 51fa117b..9d0b6c5c 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -51,26 +51,33 @@ cdef NumberNode.AxisBound.Operator _parse_python_operator(str op) except *: # Convert the user-defined axis-wise bounds for NumberNode into the # corresponding C++ objects passed to NumberNode. cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( - bound_axes_data : None | list[tuple[int, str | list[str], float | list[float]]]) except *: + bound_axes_data : None | list[tuple[int, str | list[str], float | list[float]] | + tuple[str | list[str], float | list[float]]]) except *: cdef vector[NumberNode.AxisBound] output if bound_axes_data is None: return output output.reserve(len(bound_axes_data)) + cdef optional[Py_ssize_t] cpp_axis = nullopt cdef vector[NumberNode.AxisBound.Operator] 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 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.") + if not isinstance(bound_axis_data, tuple) or len(bound_axis_data) not in [2, 3]: + raise TypeError("Each bound axis entry must be a tuple with two or " + "three elements: axis (optional), operator(s), " + "bound(s)") + + if len(bound_axis_data) == 2: + py_ops, py_bounds = bound_axis_data + cpp_axis = nullopt + else: + axis, py_ops, py_bounds = bound_axis_data + if not isinstance(axis, int): + raise TypeError("Bound axis must be an int or None.") + cpp_axis = axis if isinstance(py_ops, str): cpp_ops.resize(1) @@ -94,7 +101,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, move(cpp_ops), move(cpp_bounds))) + output.push_back(NumberNode.AxisBound(cpp_axis, move(cpp_ops), move(cpp_bounds))) return output @@ -118,7 +125,8 @@ cdef class BinaryVariable(ArraySymbol): usage of this symbol. """ def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, - subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = None): + subject_to: None | list[tuple[int, str | list[str], float | list[float]] | + tuple[str | list[str], float | list[float]]] = None): cdef vector[Py_ssize_t] cppshape = as_cppshape( tuple() if shape is None else shape ) @@ -205,7 +213,8 @@ cdef class BinaryVariable(ArraySymbol): with zf.open(info, "r") as 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 json.load(f)] + subject_to = [(item[0], item[1], item[2]) if len(item) == 3 + else (item[0], item[1]) for item in json.load(f)] return BinaryVariable(model, shape=shape_info["shape"], @@ -241,18 +250,24 @@ cdef class BinaryVariable(ArraySymbol): 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)]).""" + """Axis wise bound(s) of Binary symbol as a list of tuples where each tuple is + of the form: (axis, [operator(s)], [bound(s)]) or ([operator(s)], [bound(s)]).""" cdef vector[NumberNode.AxisBound] bound_axes = self.ptr.axis_wise_bounds() + cdef optional[Py_ssize_t] axis output = [] for i in range(bound_axes.size()): bound_axis = &bound_axes[i] + axis = bound_axis.axis() py_axis_ops = [_parse_cpp_operators(bound_axis.get_operator(j)) for j in range(bound_axis.num_operators())] py_axis_bounds = [bound_axis.get_bound(j) for j in range(bound_axis.num_bounds())] - output.append((bound_axis.axis(), py_axis_ops, py_axis_bounds)) + # axis may be nullopt + if axis.has_value(): + output.append((axis.value(), py_axis_ops, py_axis_bounds)) + else: + output.append((py_axis_ops, py_axis_bounds)) return output @@ -331,7 +346,8 @@ cdef class IntegerVariable(ArraySymbol): usage of this symbol. """ def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, - subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = None): + subject_to: None | list[tuple[int, str | list[str], float | list[float]] | + tuple[str | list[str], float | list[float]]] = None): cdef vector[Py_ssize_t] cppshape = as_cppshape( tuple() if shape is None else shape ) @@ -418,7 +434,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. # Hence we convert to tuple. We could also support lists. - subject_to = [(axis, ops, bounds) for axis, ops, bounds in json.load(f)] + subject_to = [(item[0], item[1], item[2]) if len(item) == 3 + else (item[0], item[1]) for item in json.load(f)] return IntegerVariable(model, shape=shape_info["shape"], @@ -460,18 +477,24 @@ cdef class IntegerVariable(ArraySymbol): 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)]).""" + """Axis wise bound(s) of Integer symbol as a list of tuples where each tuple is + of the form: (axis, [operator(s)], [bound(s)]) or ([operator(s)], [bound(s)]).""" cdef vector[NumberNode.AxisBound] bound_axes = self.ptr.axis_wise_bounds() + cdef optional[Py_ssize_t] axis output = [] for i in range(bound_axes.size()): bound_axis = &bound_axes[i] + axis = bound_axis.axis() py_axis_ops = [_parse_cpp_operators(bound_axis.get_operator(j)) for j in range(bound_axis.num_operators())] py_axis_bounds = [bound_axis.get_bound(j) for j in range(bound_axis.num_bounds())] - output.append((bound_axis.axis(), py_axis_ops, py_axis_bounds)) + # axis may be nullopt + if axis.has_value(): + output.append((axis.value(), py_axis_ops, py_axis_bounds)) + else: + output.append((py_axis_ops, py_axis_bounds)) return output diff --git a/tests/test_symbols.py b/tests/test_symbols.py index f7345703..abca76f0 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -762,6 +762,10 @@ def test_axis_wise_bounds(self): self.assertEqual(x.axis_wise_bounds(), [(0, ["<="], [1])]) x = model.binary((2, 3), subject_to=[(0, ["<=", "=="], np.asarray([1, 2]))]) self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) + x = model.binary((2, 3), subject_to=[(["=="], np.asarray([1]))]) + self.assertEqual(x.axis_wise_bounds(), [(["=="], [1])]) + x = model.binary((2, 3), subject_to=[("==", 1)]) + self.assertEqual(x.axis_wise_bounds(), [(["=="], [1])]) # infeasible axis-wise bounds with self.assertRaises(ValueError): @@ -770,12 +774,18 @@ def test_axis_wise_bounds(self): 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)]) + with self.assertRaises(ValueError): + model.binary((2, 3), upper_bound=[0, 1, 0, 0, 1, 0], subject_to=[(">=", 3)]) # 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])]) + with self.assertRaises(ValueError): + model.binary((2, 3), subject_to=[("==", [0, 0, 0])]) + with self.assertRaises(ValueError): + model.binary((2, 3), subject_to=[(["==", "<=", "=="], [0])]) # check bad argument format with self.assertRaises(TypeError): @@ -788,6 +798,8 @@ def test_axis_wise_bounds(self): 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=[([["<="]], [0, 0, 0])]) def test_no_shape(self): model = Model() @@ -826,6 +838,8 @@ def test_serialization(self): 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.binary(6, subject_to=[("<=", 2)]), + model.binary((2, 3), subject_to=[("<=", 2)]), ] model.lock() @@ -889,6 +903,12 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(0, [0, 0, 0, 1]) + x = model.binary((2, 2), subject_to=[("<=", 1)]) + x.set_state(0, [0, 1, 0, 0]) + # Do not satisfy axis-wise bounds + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 1, 0, 1]) + with self.subTest("invalid state index"): model = Model() x = model.binary(5) @@ -1944,6 +1964,10 @@ def test_axis_wise_bounds(self): self.assertEqual(x.axis_wise_bounds(), [(0, ["<="], [1])]) x = model.integer((2, 3), subject_to=[(0, ["<=", "=="], np.asarray([1, 2]))]) self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) + x = model.integer((2, 3), subject_to=[(["=="], np.asarray([2]))]) + self.assertEqual(x.axis_wise_bounds(), [(["=="], [2])]) + x = model.integer((2, 3), subject_to=[("==", 2)]) + self.assertEqual(x.axis_wise_bounds(), [(["=="], [2])]) # infeasible axis-wise bounds with self.assertRaises(ValueError): @@ -1952,12 +1976,18 @@ def test_axis_wise_bounds(self): 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)]) + with self.assertRaises(ValueError): + model.integer((2, 2), upper_bound=2, subject_to=[(">=", 9)]) # 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])]) + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[("==", [10, 20, 30])]) + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[(["==", "<=", "=="], 10)]) # bad argument format with self.assertRaises(TypeError): @@ -1970,10 +2000,14 @@ def test_axis_wise_bounds(self): 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=[([["=="]], 0)]) # invalid number of bound axes with self.assertRaises(ValueError): model.integer((2, 3), subject_to=[(0, "==", 1), (1, "<=", [1, 1, 1])]) + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[("==", 1), (1, "<=", [1, 1, 1])]) # Todo: we can generalize many of these tests for all decisions that can have # their state set @@ -1997,6 +2031,8 @@ def test_serialization(self): 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.integer(6, subject_to=[("<=", 2)]), + model.integer((2, 3), subject_to=[(["<="], 2)]), ] model.lock() @@ -2063,6 +2099,14 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(0, [1, 6, 2, 10]) + x = model.integer((2, 2), subject_to=[("==", 2)]) + x.set_state(0, [0, 2, 0, 0]) + # Do not satisfy axis-wise bounds + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 1, 1, 1]) + with np.testing.assert_raises(ValueError): + x.set_state(0, [0, 0, 0, 1]) + with self.subTest("array-like"): model = Model() model.states.resize(1) From 4b9339e08f76956ef88083e38bf8f8dc02501a58 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Thu, 5 Mar 2026 16:38:53 -0800 Subject: [PATCH 26/31] Simplify Integer and Binary symbols `from_zip()` --- .../dwave-optimization/nodes/numbers.hpp | 142 +- dwave/optimization/src/nodes/numbers.cpp | 573 ++++---- dwave/optimization/symbols/numbers.pyx | 6 +- tests/cpp/nodes/test_numbers.cpp | 1193 +++++++++-------- 4 files changed, 987 insertions(+), 927 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 172c92f0..cfd5f524 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -28,42 +28,52 @@ namespace dwave::optimization { /// A contiguous block of numbers. class NumberNode : public ArrayOutputMixin, public DecisionNode { public: - /// Struct for stateless axis-wise bound information. Given an `axis`, - /// define constraints on the sum of the values in each slice along `axis`. - /// Should `axis` be undefined, the constraint applies to the entire dataset. - /// Constraints can be defined for ALL slices along `axis` or PER slice - /// along `axis`. Allowable operators are defined by `Operator`. - struct AxisBound { + /// Stateless sum constraint information. + /// + /// A sum constraint constrains the sum of values within slices of the array. + /// The slices are defined along `axis` when `axis` has a value. If + /// `axis == std::nullopt`, the constraint is applied to the entire array, + /// which is treated as a flat array with a single slice. + /// + /// Constraints may be defined either: + /// - for ALL slices (the `operators` and `bounds` vectors have length 1), or + /// - PER slice (their lengths equal the number of slices along `axis`). + /// + /// Each slice sum is constrained by an `Operator` and a corresponding `bound`. + struct SumConstraint { public: - /// Allowable axis-wise bound operators. + /// Allowable operators. enum class Operator { Equal, LessEqual, GreaterEqual }; /// To reduce the # of `IntegerNode` and `BinaryNode` constructors, we /// allow only one constructor. - AxisBound(std::optional axis, std::vector axis_operators, - std::vector axis_bounds); + SumConstraint(std::optional axis, std::vector operators, + std::vector bounds); + /// Return the axis along which slices are defined. + /// If `std::nullopt`, the sum constraint applies to the entire array. std::optional axis() const { return axis_; }; - /// Obtain the bound associated with a given slice along `axis`. + /// Obtain the bound associated with a given slice. double get_bound(const ssize_t slice) const; - /// Obtain the operator associated with a given slice along `axis`. + /// Obtain the operator associated with a given slice. Operator get_operator(const ssize_t slice) const; + /// The number of bounds. ssize_t num_bounds() const { return bounds_.size(); }; + /// The number of operators. ssize_t num_operators() const { return operators_.size(); }; private: - /// The bound axis (should it be defined). If axis_=nullopt, bound - /// applies to entire dataset. + /// Axis along which slices are defined (`std::nullopt` = whole array). std::optional axis_ = std::nullopt; - /// 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 bounds PER + /// Operator for ALL 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 slices (vector has length one) or bounds PER slice + /// (length of vector is equal to the number of slices). std::vector bounds_; }; @@ -107,9 +117,9 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Initialize the state of the node randomly template void initialize_state(State& state, Generator& rng) const { - // 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."); + // Currently do not support random node initialization with sum constraints. + if (sum_constraint_.size() > 0) { + throw std::invalid_argument("Cannot randomly initialize_state with sum constraints."); } std::vector values; @@ -153,17 +163,19 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // in a given index. 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; + /// Return the stateless sum constraint information i.e. sum_constraint_. + const std::vector& sum_constraint() const; - /// Return the state-dependent sum of the values within each slice - /// 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(const State& state) const; + /// If the node is subject to sum constraint(s), we track the state + /// dependent sum of the values within each slice per constraint. The + /// returned vector is indexed in the same ordering as the constraints + /// given by `sum_constraints()`. + const std::vector>& sum_constraint_sums(const State& state) const; protected: explicit NumberNode(std::span shape, std::vector lower_bound, - std::vector upper_bound, std::vector bound_axes = {}); + std::vector upper_bound, + std::vector sum_constraint = {}); // Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; @@ -171,10 +183,10 @@ 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 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; + /// Update the relevant sum constraint sums given that the value stored at + /// `index` is changed by `value_change` in a given state. + void update_sum_constraint_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_; @@ -184,10 +196,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::vector lower_bounds_; std::vector upper_bounds_; - /// Stateless information on each bound axis. - std::vector bound_axes_info_; - /// Indicator variable that all axis-wise bound operators are "==". - bool bound_axis_ops_all_equals_; + /// Stateless information on each sum constraint. + std::vector sum_constraint_; + /// Indicator variable that all sum constraint operators are "==". + bool sum_constraint_all_equals_; }; /// A contiguous block of integer numbers. @@ -201,45 +213,45 @@ class IntegerNode : public NumberNode { // Default to a single scalar integer with default bounds IntegerNode() : IntegerNode({}) {} - // Create an integer array with the user-defined index- and axis-wise bounds. - // Index-wise bounds default to the specified default bounds. By default, - // there are no axis-wise bounds. + // Create an integer array with the user-defined index-wise bounds and sum + // constraints. Index-wise bounds default to the specified default bounds. + // By default, there are no sum constraints. IntegerNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(std::span shape, std::optional> lower_bound, - double upper_bound, std::vector bound_axes = {}); + double upper_bound, std::vector sum_constraint = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); // Overloads needed by the Node ABC *************************************** @@ -269,44 +281,44 @@ 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 index- and axis-wise bounds. - // Index-wise bounds default to lower_bound = 0.0 and upper_bound = 1.0. By - // default, there are no axis-wise bounds. + // Create a binary array with the user-defined index-wise bounds and sum + // constraints. Index-wise bounds default to lower_bound = 0.0 and + // upper_bound = 1.0. By default, there are no sum constraints. BinaryNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(std::span shape, std::optional> lower_bound, - double upper_bound, std::vector bound_axes = {}); + double upper_bound, std::vector sum_constraint = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound, - double upper_bound, std::vector bound_axes = {}); + double upper_bound, std::vector sum_constraint = {}); BinaryNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes = {}); + std::vector sum_constraint = {}); // 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 59f84934..3d4f6f8b 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -29,17 +29,15 @@ namespace dwave::optimization { -NumberNode::AxisBound::AxisBound(std::optional bound_axis, - std::vector axis_operators, - std::vector axis_bounds) - : axis_(bound_axis), - operators_(std::move(axis_operators)), - bounds_(std::move(axis_bounds)) { +NumberNode::SumConstraint::SumConstraint(std::optional axis, + std::vector operators, + std::vector bounds) + : axis_(axis), operators_(std::move(operators)), bounds_(std::move(bounds)) { const size_t num_operators = operators_.size(); const size_t num_bounds = bounds_.size(); if ((num_operators == 0) || (num_bounds == 0)) { - throw std::invalid_argument("Axis-wise `operators` and `bounds` must have non-zero size."); + throw std::invalid_argument("`operators` and `bounds` must have non-zero size."); } if (!axis_.has_value() && (num_operators != 1 || num_bounds != 1)) { @@ -50,19 +48,21 @@ NumberNode::AxisBound::AxisBound(std::optional bound_axis, // If `operators` and `bounds` are both defined PER slice along `axis`, // they must have the same size. if ((num_operators > 1) && (num_bounds > 1) && (num_bounds != num_operators)) { + assert(axis.has_value()); throw std::invalid_argument( - "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); + "`operators` and `bounds` should have same size if neither has size 1."); } } -double NumberNode::AxisBound::get_bound(const ssize_t slice) const { +double NumberNode::SumConstraint::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::AxisBound::Operator NumberNode::AxisBound::get_operator(const ssize_t slice) const { +NumberNode::SumConstraint::Operator NumberNode::SumConstraint::get_operator( + const ssize_t slice) const { assert(0 <= slice); if (operators_.size() == 1) return operators_[0]; assert(slice < static_cast(operators_.size())); @@ -71,27 +71,28 @@ NumberNode::AxisBound::Operator NumberNode::AxisBound::get_operator(const ssize_ /// State dependant data attached to NumberNode struct NumberNodeStateData : public ArrayNodeStateData { - // User does not provide axis-wise bounds. + // User does not provide sum constraints. NumberNodeStateData(std::vector input) : ArrayNodeStateData(std::move(input)) {} - // User provides axis-wise bounds. - NumberNodeStateData(std::vector input, std::vector> bound_axes_sums) + // User provides sum constraints. + NumberNodeStateData(std::vector input, + std::vector> sum_constraint_sums) : ArrayNodeStateData(std::move(input)), - bound_axes_sums(std::move(bound_axes_sums)), - prior_bound_axes_sums(this->bound_axes_sums) {} + sum_constraint_sums(std::move(sum_constraint_sums)), + prior_sum_constraint_sums(this->sum_constraint_sums) {} std::unique_ptr copy() const override { return std::make_unique(*this); } - /// For each bound axis and for each slice along said axis, we track the - /// sum of the values within the slice. - /// bound_axes_sums[i][j] = "sum of the values within the jth slice along - /// the ith bound axis" - /// Note 1) That "ith bound axis" does not necessarily mean the ith axis. - /// Note 2) If axis = nullopt, the entire array is considered the slice. - std::vector> bound_axes_sums; + /// For each sum constraint, track the sum of the values within each slice. + /// `sum_constraint_sums[i][j]` is the sum of the values within the `j`th slice + /// along the `axis`* defined by the `i`th sum constraint. + /// + /// (*) If `axis == std::nullopt`, the constraint is applied to the entire + /// array, which is treated as a flat array with a single slice. + std::vector> sum_constraint_sums; // Store a copy for NumberNode::revert() and commit() - std::vector> prior_bound_axes_sums; + std::vector> prior_sum_constraint_sums; }; double const* NumberNode::buff(const State& state) const noexcept { @@ -106,83 +107,86 @@ 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 -/// slice along each bound axis. -std::vector> get_bound_axes_sums(const NumberNode* node, - const std::vector& number_data) { +/// Given a NumberNode and an assignment of its variables (`number_data`), +/// compute and return a vector containing the sum of values for each slice +/// along the specified `axis`*. +/// +/// (*) If `axis == std::nullopt`, the constraint is applied to the entire +/// array, which is treated as a flat array with a single slice. +std::vector> get_sum_constraint_sums(const NumberNode* node, + const std::vector& number_data) { std::span node_shape = node->shape(); - 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 <= static_cast(node_shape.size())); + const auto& sum_constraint = node->sum_constraint(); + const ssize_t num_sum_constraints = static_cast(sum_constraint.size()); + assert(num_sum_constraints <= 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 slice to 0. Define bound_axes_sums[i][j] = "sum of the values - // within the jth slice along the ith bound axis". - std::vector> bound_axes_sums; - bound_axes_sums.reserve(num_bound_axes); - for (const NumberNode::AxisBound& axis_info : bound_axes_info) { - const std::optional axis = axis_info.axis(); - // Handle the case where the bound applies to the entire array. + // For each sum constraint, initialize the sum of the values contained in + // each of its slice to 0. + std::vector> sum_constraint_sums; + sum_constraint_sums.reserve(num_sum_constraints); + for (const NumberNode::SumConstraint& constraint : sum_constraint) { + const std::optional axis = constraint.axis(); + // Handle the case where the sum constraint applies to the entire array. if (!axis.has_value()) { - bound_axes_sums.emplace_back(1, 0.0); + // Array is treated as a flat array with a single axis. + sum_constraint_sums.emplace_back(1, 0.0); continue; } assert(axis.has_value()); assert(0 <= *axis && *axis < static_cast(node_shape.size())); // Emplace an all zeros vector of size equal to the number of slice - // along the given bound axis (axis_info.axis). - bound_axes_sums.emplace_back(node_shape[*axis], 0.0); + // along the given constrained axis. + sum_constraint_sums.emplace_back(node_shape[*axis], 0.0); } // 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 sum of the appropriate slice along each bound axis. - for (ssize_t bound_axis = 0; bound_axis < num_bound_axes; ++bound_axis) { - const std::optional axis = bound_axes_info[bound_axis].axis(); - // Handle the case where the bound applies to the entire array. + // Increment the sum of the appropriate slice per sum constraint. + for (ssize_t i = 0; i < num_sum_constraints; ++i) { + const std::optional axis = sum_constraint[i].axis(); + // Handle the case where the sum constraint applies to the entire array. if (!axis.has_value()) { - assert(bound_axes_sums[bound_axis].size() == 1); - bound_axes_sums[bound_axis].front() += *it; + assert(sum_constraint_sums[i].size() == 1); + sum_constraint_sums[i].front() += *it; continue; } - assert(axis.has_value() && 0 <= *axis && - *axis < static_cast(it.location().size())); + assert(axis.has_value()); + assert(0 <= *axis && *axis < static_cast(it.location().size())); const ssize_t slice = it.location()[*axis]; - assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); - bound_axes_sums[bound_axis][slice] += *it; + assert(0 <= slice); + assert(slice < static_cast(sum_constraint_sums[i].size())); + sum_constraint_sums[i][slice] += *it; } } - return bound_axes_sums; + return sum_constraint_sums; } -/// Determine whether the sum of the values within each slice 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()); - // 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]; +/// Determine whether the sum constraints are satisfied. +bool satisfies_sum_constraint(const std::vector& sum_constraint, + const std::vector>& sum_constraint_sums) { + assert(sum_constraint.size() == sum_constraint_sums.size()); + // Iterate over each sum constraint. + for (ssize_t i = 0, stop_i = static_cast(sum_constraint.size()); i < stop_i; ++i) { + const auto& constraint = sum_constraint[i]; + const auto& contraint_sums = sum_constraint_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()); + // Return `false` if any slice does not satisfy the constraint. + for (ssize_t slice = 0, stop_slice = static_cast(contraint_sums.size()); slice < stop_slice; ++slice) { - switch (bound_axis_info.get_operator(slice)) { - case NumberNode::AxisBound::Operator::Equal: - if (bound_axis_sums[slice] != bound_axis_info.get_bound(slice)) return false; + switch (constraint.get_operator(slice)) { + case NumberNode::SumConstraint::Operator::Equal: + if (contraint_sums[slice] != constraint.get_bound(slice)) return false; break; - case NumberNode::AxisBound::Operator::LessEqual: - if (bound_axis_sums[slice] > bound_axis_info.get_bound(slice)) return false; + case NumberNode::SumConstraint::Operator::LessEqual: + if (contraint_sums[slice] > constraint.get_bound(slice)) return false; break; - case NumberNode::AxisBound::Operator::GreaterEqual: - if (bound_axis_sums[slice] < bound_axis_info.get_bound(slice)) return false; + case NumberNode::SumConstraint::Operator::GreaterEqual: + if (contraint_sums[slice] < constraint.get_bound(slice)) return false; break; default: assert(false && "Unexpected operator type."); @@ -204,19 +208,19 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } } - if (bound_axes_info_.size() == 0) { // No bound axes to consider. + if (sum_constraint_.size() == 0) { // No sum constraints to consider. emplace_data_ptr(state, std::move(number_data)); } else { - // Given the assingnment to NumberNode `number_data`, compute the sum - // of the values within each slice along each bound axis. - std::vector> bound_axes_sums = get_bound_axes_sums(this, number_data); + // Given the assignment to NumberNode `number_data`, compute the sum + // of the values within each slice per sum constraint. + auto sum_constraint_sums = get_sum_constraint_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_sum_constraint(sum_constraint_, sum_constraint_sums)) { + throw std::invalid_argument("Initialized values do not satisfy sum constraint(s)."); } emplace_data_ptr(state, std::move(number_data), - std::move(bound_axes_sums)); + std::move(sum_constraint_sums)); } } @@ -250,27 +254,26 @@ std::vector undo_shift_axis_data(const std::span span, c 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` +/// Given a `sum`, operator (`op`), and a `bound`, determine the non-negative amount +/// `delta` needed to be added to `sum` to satisfy the constraint: (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 double sum, const NumberNode::AxisBound::Operator op, - const double bound) { +/// Throws an error if `delta` is negative (corresponding with an infeasible sum constraint) +double sum_constraint_delta(const double sum, const NumberNode::SumConstraint::Operator op, + const double bound) { switch (op) { - 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. + case NumberNode::SumConstraint::Operator::Equal: + if (sum > bound) throw std::invalid_argument("Infeasible sum constraint."); + // If error was not thrown, return amount needed to satisfy constraint. return bound - sum; - case NumberNode::AxisBound::Operator::LessEqual: - if (sum > bound) throw std::invalid_argument("Infeasible axis-wise bounds."); - // If error was not thrown, sum satisfies bound. + case NumberNode::SumConstraint::Operator::LessEqual: + if (sum > bound) throw std::invalid_argument("Infeasible sum constraint."); + // If error was not thrown, sum satisfies constraint. return 0.0; - case NumberNode::AxisBound::Operator::GreaterEqual: + case NumberNode::SumConstraint::Operator::GreaterEqual: // If sum is less than bound, return the amount needed to equal it. - // Otherwise, sum satisfies bound. + // Otherwise, sum satisfies constraint. return (sum < bound) ? (bound - sum) : 0.0; default: assert(false && "Unexpected operator type."); @@ -278,13 +281,13 @@ double compute_bound_axis_slice_delta(const double sum, const NumberNode::AxisBo } } -/// Given a NumberNode and exactly one axis-wise bound, assign values to -/// `values` (in-place) to satisfy the axis-wise bound. This method +/// Given a NumberNode and exactly one sum constraint, assign values to +/// `values` (in-place) to satisfy the constraint. This method /// A) Initially sets `values[i] = lower_bound(i)` for all i. /// B) Incremements the values within each slice 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) { +/// the constraint (should this be possible). +void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, + std::vector& values) { const std::span node_shape = node->shape(); const ssize_t ndim = node_shape.size(); @@ -292,21 +295,20 @@ 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 slice sums for the bound axis. To improve performance, + // 2) Determine the slice sums for the sum constraint. 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::AxisBound& bound_axis_info = node->axis_wise_bounds().front(); - const std::optional bound_axis = bound_axis_info.axis(); - - // Handle the case where the bound applies to the entire array. - if (!bound_axis.has_value()) { - assert(bound_axis_sums.size() == 1); + assert(node->sum_constraint().size() == 1); + const std::vector constraint_sums = get_sum_constraint_sums(node, values).front(); + // Obtain the stateless sum constraint information. + const NumberNode::SumConstraint& constraint = node->sum_constraint().front(); + const std::optional axis = constraint.axis(); + + // Handle the case where the constraint applies to the entire array. + if (!axis.has_value()) { + assert(constraint_sums.size() == 1); // Determine the amount needed to adjust the values within the array. - double delta = compute_bound_axis_slice_delta(bound_axis_sums.front(), - bound_axis_info.get_operator(0), - bound_axis_info.get_bound(0)); + double delta = sum_constraint_delta(constraint_sums.front(), constraint.get_operator(0), + constraint.get_bound(0)); if (delta == 0) return; // Bound is satisfied for entire array. for (ssize_t i = 0, stop = node->size(); i < stop; ++i) { @@ -320,34 +322,33 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, } } - if (delta != 0) throw std::invalid_argument("Infeasible axis-wise bounds."); + if (delta != 0) throw std::invalid_argument("Infeasible sum constraint."); return; } - assert(bound_axis.has_value() && 0 <= *bound_axis && *bound_axis < ndim); - // We need a way to iterate over each slice along the bound axis and adjust - // it`s values until they satisfy the axis-wise bounds. We do this by + assert(axis.has_value() && 0 <= *axis && *axis < ndim); + // We need a way to iterate over each slice along the constrainted axis and + // adjust its values until they satisfy the constraint. We do this by // defining an iterator of `values` that traverses each slice one 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); + // such that the data for the constrained axis is moved to position 0. + const std::vector buff_shape = shift_axis_data(node_shape, *axis); + const std::vector buff_strides = shift_axis_data(node->strides(), *axis); // Define an iterator for `values` corresponding with the beginning of - // slice 0 along the bound axis. + // slice 0 along the constrained axis. const BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), buff_strides.data()); - // Determine the size of each slice along the bound axis. + // Determine the size of each slice along the constrained axis. const ssize_t slice_size = std::accumulate(buff_shape.begin() + 1, buff_shape.end(), 1.0, std::multiplies()); - // 3) Iterate over each slice 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) { + // 3) Iterate over each slice and adjust its values until they satisfy the + // sum constraint. + for (ssize_t slice = 0, stop = node_shape[*axis]; slice < stop; ++slice) { // Determine the amount needed to adjust the values within the slice. - double delta = compute_bound_axis_slice_delta(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. + double delta = sum_constraint_delta(constraint_sums[slice], constraint.get_operator(slice), + constraint.get_bound(slice)); + if (delta == 0) continue; // Sum constraint is satisfied for slice. assert(delta >= 0); // Should only increment. // Determine how much we need to offset `slice_0_it` to get to the @@ -358,12 +359,11 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, 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); + ssize_t index = + ravel_multi_index(undo_shift_axis_data(slice_it.location(), *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_it.location())); + assert(std::ranges::equal(shift_axis_data(unravel_index(index, node_shape), *axis), + 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); @@ -371,11 +371,11 @@ void construct_state_given_exactly_one_bound_axis(const NumberNode* node, if (inc > 0) { // Apply the increment to both `it` and `delta`. *slice_it += inc; delta -= inc; - if (delta == 0) break; // Axis-wise bounds are now satisfied for slice. + if (delta == 0) break; // Sum constraint is satisfied for slice. } } - if (delta != 0) throw std::invalid_argument("Infeasible axis-wise bounds."); + if (delta != 0) throw std::invalid_argument("Infeasible sum constraint."); } } @@ -383,24 +383,24 @@ 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, initialize by default. + if (sum_constraint_.size() == 0) { + // No sum constraint to consider, initialize by default. for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { values.push_back(default_value(i)); } initialize_state(state, std::move(values)); - } else if (bound_axes_info_.size() == 1) { - construct_state_given_exactly_one_bound_axis(this, values); + } else if (sum_constraint_.size() == 1) { + construct_state_given_exactly_one_sum_constraint(this, values); initialize_state(state, std::move(values)); } else { - assert(false && "Multiple axis-wise bound not yet supported."); + assert(false && "Multiple sum constraints not yet supported."); unreachable(); } } void NumberNode::propagate(State& state) const { - // Should only propagate states that obey the axis-wise bounds. - assert(satisfies_axis_wise_bounds(bound_axes_info_, bound_axis_sums(state))); + // Should only propagate states that obey the sum constraint(s). + assert(satisfies_sum_constraint(sum_constraint_, sum_constraint_sums(state))); // Technically vestigial but will keep it for forms sake. for (const auto& sv : successors()) { sv->update(state, sv.index); @@ -409,15 +409,15 @@ void NumberNode::propagate(State& state) const { void NumberNode::commit(State& state) const noexcept { auto node_data = data_ptr(state); - // Manually store a copy of bound_axes_sums. - node_data->prior_bound_axes_sums = node_data->bound_axes_sums; + // Manually store a copy of sum_constraint_sums. + node_data->prior_sum_constraint_sums = node_data->sum_constraint_sums; node_data->commit(); } void NumberNode::revert(State& state) const noexcept { auto node_data = data_ptr(state); - // Manually reset bound_axes_sums. - node_data->bound_axes_sums = node_data->prior_bound_axes_sums; + // Manually reset sum_constraint_sums. + node_data->sum_constraint_sums = node_data->prior_sum_constraint_sums; node_data->revert(); } @@ -431,14 +431,14 @@ 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 change occurred and axis-wise bounds exist, update bound axis sums. - // Nothing to update if all axis bound operators are Equals. - if (!bound_axis_ops_all_equals_ && bound_axes_info_.size() > 0) { + // If change occurred and sum constraint exist, update running sums. + // Nothing to update if all sum constraints are Equals. + if (!sum_constraint_all_equals_ && sum_constraint_.size() > 0) { 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); + update_sum_constraint_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); + update_sum_constraint_sums(state, j, -difference); } } } @@ -487,19 +487,19 @@ 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 occurred and axis-wise bounds exist, update bound axis sums. - if (bound_axes_info_.size() > 0) { - update_bound_axis_slice_sums(state, index, value - diff(state).back().old); + // If change occurred and sum constraint exist, update running sums. + if (sum_constraint_.size() > 0) { + update_sum_constraint_sums(state, index, value - diff(state).back().old); } } } -const std::vector& NumberNode::axis_wise_bounds() const { - return bound_axes_info_; +const std::vector& NumberNode::sum_constraint() const { + return sum_constraint_; } -const std::vector>& NumberNode::bound_axis_sums(const State& state) const { - return data_ptr(state)->bound_axes_sums; +const std::vector>& NumberNode::sum_constraint_sums(const State& state) const { + return data_ptr(state)->sum_constraint_sums; } template @@ -514,14 +514,15 @@ double get_extreme_index_wise_bound(const std::vector& bound) { return *it; } -bool all_bound_axis_operators_are_equals(std::vector& bound_axes_info) { - for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - for (ssize_t i = 0, stop = bound_axis_info.num_operators(); i < stop; ++i) { - const NumberNode::AxisBound::Operator op = bound_axis_info.get_operator(i); - if (op != NumberNode::AxisBound::Operator::Equal) return false; +bool all_sum_constraint_operators_are_equals( + std::vector& sum_constraint) { + for (const NumberNode::SumConstraint& constraint : sum_constraint) { + for (ssize_t i = 0, stop = constraint.num_operators(); i < stop; ++i) { + if (constraint.get_operator(i) != NumberNode::SumConstraint::Operator::Equal) + return false; } } - // Vacuously true if there are no axis-wise bounds. + // Vacuously true if there are no sum constraints. return true; } @@ -553,82 +554,80 @@ 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(); - if (bound_axes_info.size() == 0) return; // No bound axes to check. +/// Check the user defined sum constraint(s). +void check_sum_constraints(const NumberNode* node) { + const std::vector& sum_constraint = node->sum_constraint(); + if (sum_constraint.size() == 0) return; // No sum constraints to check. const std::span shape = node->shape(); - // Used to assess if an axis have been bound multiple times. - std::vector axis_bound(shape.size(), false); - // Used to assess if multiple bounds have been applied to the entire array. - bool array_bound = false; - - // For each set of bound axis data - for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - const std::optional axis = bound_axis_info.axis(); - const ssize_t num_operators = static_cast(bound_axis_info.num_operators()); - const ssize_t num_bounds = static_cast(bound_axis_info.num_bounds()); - - // Handle the case where the bound applies to the entire array. + // Used to assess if an axis is subject to multiple constraints. + std::vector constrained_axis(shape.size(), false); + // Used to assess if array is subject to multiple constraints. + bool constrained_array = false; + + for (const NumberNode::SumConstraint& constraint : sum_constraint) { + const std::optional axis = constraint.axis(); + const ssize_t num_operators = static_cast(constraint.num_operators()); + const ssize_t num_bounds = static_cast(constraint.num_bounds()); + + // Handle the case where the constraint applies to the entire array. if (!axis.has_value()) { - // Checked in AxisBound constructor + // Checked in SumConstraint constructor assert(num_operators == 1 && num_bounds == 1); - if (array_bound) - throw std::invalid_argument("Cannot define multiple bounds for the entire array."); - array_bound = true; + if (constrained_array) + throw std::invalid_argument( + "Cannot define multiple sum constraints for the entire number array."); + constrained_array = true; continue; } assert(axis.has_value()); if (*axis < 0 || *axis >= static_cast(shape.size())) { - throw std::invalid_argument("Invalid bound axis given number array shape."); + throw std::invalid_argument("Invalid constrained axis given number array shape."); } if ((num_operators > 1) && (num_operators != shape[*axis])) { - throw std::invalid_argument( - "Invalid number of axis-wise operators given number array shape."); + throw std::invalid_argument("Invalid number of operators given number array shape."); } if ((num_bounds > 1) && (num_bounds != shape[*axis])) { - throw std::invalid_argument( - "Invalid number of axis-wise bounds given number array shape."); + throw std::invalid_argument("Invalid number of bounds given number array shape."); } - // Checked in AxisBound constructor + // Checked in SumConstraint constructor assert(num_operators == num_bounds || num_operators == 1 || num_bounds == 1); - if (axis_bound[*axis]) { + if (constrained_axis[*axis]) { throw std::invalid_argument( - "Cannot define multiple axis-wise bounds for a single axis."); + "Cannot define multiple sum constraints for a single axis."); } - axis_bound[*axis] = true; + constrained_axis[*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."); + // *Currently*, we only support one sum constraint. + if (sum_constraint.size() > 1) { + throw std::invalid_argument("Can define at most one sum constraint per number array."); } - // There are fasters ways to check whether the axis-wise bounds are feasible. + // There are fasters ways to check whether the sum constraints 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); + construct_state_given_exactly_one_sum_constraint(node, values); } // 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 sum_constraint) : 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_(std::move(bound_axes)), - bound_axis_ops_all_equals_(all_bound_axis_operators_are_equals(bound_axes_info_)) { + sum_constraint_(std::move(sum_constraint)), + sum_constraint_all_equals_(all_sum_constraint_operators_are_equals(sum_constraint_)) { if ((shape.size() > 0) && (shape[0] < 0)) { throw std::invalid_argument("Number array cannot have dynamic size."); } @@ -638,56 +637,55 @@ NumberNode::NumberNode(std::span shape, std::vector lower } check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); - check_axis_wise_bounds(this); + check_sum_constraints(this); } -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_; - assert(value_change != 0); // Should not call when no change occurs. - assert(bound_axes_info.size() != 0); // Should only call where applicable. +void NumberNode::update_sum_constraint_sums(State& state, const ssize_t index, + const double value_change) const { + const auto& sum_constraint = this->sum_constraint(); + assert(value_change != 0); // Should not call when no change occurs. + assert(sum_constraint.size() != 0); // Should only call where applicable. // Get multidimensional indices for `index` so we can identify the slices - // `index` lies on per bound axis. + // `index` lies on per sum constraint. const std::vector multi_index = unravel_index(index, this->shape()); - assert(bound_axes_info.size() <= multi_index.size()); - // Get the slice sums of all bound axes. - auto& bound_axes_sums = data_ptr(state)->bound_axes_sums; - assert(bound_axes_info.size() == bound_axes_sums.size()); + assert(sum_constraint.size() <= multi_index.size()); + // Get the slice sums for all sum constraints. + auto& sum_constraint_sums = data_ptr(state)->sum_constraint_sums; + assert(sum_constraint.size() == sum_constraint_sums.size()); - // For each bound axis - for (ssize_t bound_axis = 0, stop = static_cast(bound_axes_info.size()); - bound_axis < stop; ++bound_axis) { - const std::optional axis = bound_axes_info[bound_axis].axis(); + // For each sum constraint. + for (ssize_t i = 0, stop = static_cast(sum_constraint.size()); i < stop; ++i) { + const std::optional axis = sum_constraint[i].axis(); - // Handle the case where the bound applies to the entire array. + // Handle the case where the constraint applies to the entire array. if (!axis.has_value()) { - assert(bound_axes_sums[bound_axis].size() == 1); - bound_axes_sums[bound_axis].front() += value_change; + assert(sum_constraint_sums[i].size() == 1); + sum_constraint_sums[i].front() += value_change; continue; } assert(axis.has_value() && 0 <= *axis && *axis < static_cast(multi_index.size())); - // Get the slice along the bound axis the `value_change` occurs in. + // Get the slice along the constrained axis the `value_change` occurs in. const ssize_t slice = multi_index[*axis]; - assert(0 <= slice && slice < static_cast(bound_axes_sums[bound_axis].size())); - // Offset sum in slice. - bound_axes_sums[bound_axis][slice] += value_change; + assert(0 <= slice && slice < static_cast(sum_constraint_sums[i].size())); + sum_constraint_sums[i][slice] += value_change; // Offset slice sum. } } // Integer Node *************************************************************** -/// Check the user defined axis-wise bounds for IntegerNode -void check_bound_axes_integrality(const std::vector& bound_axes_info) { - if (bound_axes_info.size() == 0) return; // No bound axes to check. +/// Check the user defined sum constraint for IntegerNode. +void check_sum_constraint_integrality( + const std::vector& sum_constraint) { + if (sum_constraint.size() == 0) return; // No sum constraints to check. - for (const NumberNode::AxisBound& bound_axis_info : bound_axes_info) { - for (ssize_t i = 0, stop = bound_axis_info.num_bounds(); i < stop; ++i) { - const double bound = bound_axis_info.get_bound(i); + for (const NumberNode::SumConstraint& constraint : sum_constraint) { + for (ssize_t slice = 0, stop = constraint.num_bounds(); slice < stop; ++slice) { + const double bound = constraint.get_bound(slice); if (bound != std::floor(bound)) { throw std::invalid_argument( - "Axis wise bounds for integral number arrays must be integral."); + "Sum constraint(s) for integral arrays must be integral."); } } } @@ -696,13 +694,14 @@ void check_bound_axes_integrality(const std::vector& boun IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector 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}, - (check_bound_axes_integrality(bound_axes), std::move(bound_axes))) { + std::vector sum_constraint) + : 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}, + (check_sum_constraint_integrality(sum_constraint), std::move(sum_constraint))) { if (min_ < minimum_lower_bound || max_ > maximum_upper_bound) { throw std::invalid_argument("range provided for integers exceeds supported range"); } @@ -711,58 +710,58 @@ 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 sum_constraint) : IntegerNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode({size}, std::move(lower_bound), std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode(shape, std::vector{lower_bound}, std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode({size}, std::vector{lower_bound}, std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode(shape, std::move(lower_bound), std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::vector bound_axes) + double upper_bound, std::vector sum_constraint) : IntegerNode({size}, std::move(lower_bound), std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, - double upper_bound, std::vector bound_axes) + double upper_bound, std::vector sum_constraint) : IntegerNode(std::span(shape), std::vector{lower_bound}, - std::vector{upper_bound}, std::move(bound_axes)) {} + std::vector{upper_bound}, std::move(sum_constraint)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : IntegerNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} bool IntegerNode::integral() const { return true; } @@ -780,9 +779,9 @@ 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 occurred and axis-wise bounds exist, update bound axis sums. - if (bound_axes_info_.size() > 0) { - update_bound_axis_slice_sums(state, index, value - diff(state).back().old); + // If change occurred and sum constraint exist, update running sums. + if (sum_constraint_.size() > 0) { + update_sum_constraint_sums(state, index, value - diff(state).back().old); } } } @@ -824,65 +823,65 @@ 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 sum_constraint) : IntegerNode(shape, limit_bound_to_bool_domain(lower_bound), - limit_bound_to_bool_domain(upper_bound), std::move(bound_axes)) {} + limit_bound_to_bool_domain(upper_bound), std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode({size}, std::move(lower_bound), std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(shape, std::vector{lower_bound}, std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode({size}, std::vector{lower_bound}, std::move(upper_bound), - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(shape, std::move(lower_bound), std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::vector bound_axes) + double upper_bound, std::vector sum_constraint) : BinaryNode({size}, std::move(lower_bound), std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode(std::span(shape), std::vector{lower_bound}, - std::vector{upper_bound}, std::move(bound_axes)) {} + std::vector{upper_bound}, std::move(sum_constraint)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::vector bound_axes) + std::vector sum_constraint) : BinaryNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(bound_axes)) {} + std::move(sum_constraint)) {} void BinaryNode::flip(State& state, ssize_t i) const { auto ptr = data_ptr(state); @@ -891,11 +890,11 @@ void BinaryNode::flip(State& state, ssize_t i) const { // 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 change occurred and axis-wise bounds exist, update bound axis sums. - if (bound_axes_info_.size() > 0) { + // If change occurred and sum constraint exist, update running sums. + if (sum_constraint_.size() > 0) { // If value changed from 0 -> 1, update by 1. // If value changed from 1 -> 0, update by -1. - update_bound_axis_slice_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); + update_sum_constraint_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); } } } diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index 9d0b6c5c..ec4ee3d3 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -213,8 +213,7 @@ cdef class BinaryVariable(ArraySymbol): with zf.open(info, "r") as 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 = [(item[0], item[1], item[2]) if len(item) == 3 - else (item[0], item[1]) for item in json.load(f)] + subject_to = [tuple(item) for item in json.load(f)] return BinaryVariable(model, shape=shape_info["shape"], @@ -434,8 +433,7 @@ cdef class IntegerVariable(ArraySymbol): with zf.open(info, "r") as 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 = [(item[0], item[1], item[2]) if len(item) == 3 - else (item[0], item[1]) for item in json.load(f)] + subject_to = [tuple(item) for item in json.load(f)] return IntegerVariable(model, shape=shape_info["shape"], diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index 59b35671..b047c6f9 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -26,102 +26,102 @@ using Catch::Matchers::RangeEquals; namespace dwave::optimization { -using AxisBound = NumberNode::AxisBound; -using Operator = NumberNode::AxisBound::Operator; -using NumberNode::AxisBound::Operator::Equal; -using NumberNode::AxisBound::Operator::GreaterEqual; -using NumberNode::AxisBound::Operator::LessEqual; +using SumConstraint = NumberNode::SumConstraint; +using Operator = NumberNode::SumConstraint::Operator; +using NumberNode::SumConstraint::Operator::Equal; +using NumberNode::SumConstraint::Operator::GreaterEqual; +using NumberNode::SumConstraint::Operator::LessEqual; -TEST_CASE("AxisBound") { - GIVEN("AxisBound(axis = nullopt, operators = {}, bounds = {1.0})") { - REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {}, {1.0}), - "Axis-wise `operators` and `bounds` must have non-zero size."); +TEST_CASE("SumConstraint") { + GIVEN("SumConstraint(axis = nullopt, operators = {}, bounds = {1.0})") { + REQUIRE_THROWS_WITH(SumConstraint(std::nullopt, {}, {1.0}), + "`operators` and `bounds` must have non-zero size."); } - GIVEN("AxisBound(axis = nullopt, operators = {<=}, bounds = {})") { - REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {LessEqual}, {}), - "Axis-wise `operators` and `bounds` must have non-zero size."); + GIVEN("SumConstraint(axis = nullopt, operators = {<=}, bounds = {})") { + REQUIRE_THROWS_WITH(SumConstraint(std::nullopt, {LessEqual}, {}), + "`operators` and `bounds` must have non-zero size."); } - GIVEN("AxisBound(axis = nullopt, operators = {<=, ==}, bounds = {1.0})") { - REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {LessEqual, Equal}, {1.0}), + GIVEN("SumConstraint(axis = nullopt, operators = {<=, ==}, bounds = {1.0})") { + REQUIRE_THROWS_WITH(SumConstraint(std::nullopt, {LessEqual, Equal}, {1.0}), "If `axis` is undefined, `operators` and `bounds` must have size 1."); } - GIVEN("AxisBound(axis = nullopt, operators = {<=}, bounds = {1.0, 2.0})") { - REQUIRE_THROWS_WITH(AxisBound(std::nullopt, {LessEqual}, {1.0, 2.0}), + GIVEN("SumConstraint(axis = nullopt, operators = {<=}, bounds = {1.0, 2.0})") { + REQUIRE_THROWS_WITH(SumConstraint(std::nullopt, {LessEqual}, {1.0, 2.0}), "If `axis` is undefined, `operators` and `bounds` must have size 1."); } - 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("SumConstraint(axis = 0, operators = {}, bounds = {1.0})") { + REQUIRE_THROWS_WITH(SumConstraint(0, {}, {1.0}), + "`operators` and `bounds` must have non-zero size."); } - GIVEN("AxisBound(axis = 0, operators = {<=}, bounds = {})") { - REQUIRE_THROWS_WITH(AxisBound(0, {LessEqual}, {}), - "Axis-wise `operators` and `bounds` must have non-zero size."); + GIVEN("SumConstraint(axis = 0, operators = {<=}, bounds = {})") { + REQUIRE_THROWS_WITH(SumConstraint(0, {LessEqual}, {}), + "`operators` and `bounds` must have non-zero size."); } - GIVEN("AxisBound(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { + GIVEN("SumConstraint(axis = 1, operators = {<=, ==, ==}, bounds = {2.0, 1.0})") { REQUIRE_THROWS_WITH( - AxisBound(1, {LessEqual, Equal, Equal}, {2.0, 1.0}), - "Axis-wise `operators` and `bounds` should have same size if neither has size 1."); + SumConstraint(1, {LessEqual, Equal, Equal}, {2.0, 1.0}), + "`operators` and `bounds` should have same size if neither has size 1."); } - GIVEN("AxisBound(axis = nullopt, operators = {==}, bounds = {1.0})") { - AxisBound bound_axis(std::nullopt, {Equal}, {1.0}); + GIVEN("SumConstraint(axis = nullopt, operators = {==}, bounds = {1.0})") { + SumConstraint sum_constraint(std::nullopt, {Equal}, {1.0}); - THEN("The bound axis info is correct") { - CHECK(bound_axis.axis() == std::nullopt); - CHECK(bound_axis.num_bounds() == 1); - CHECK(bound_axis.get_bound(0) == 1.0); - CHECK(bound_axis.num_operators() == 1); - CHECK(bound_axis.get_operator(0) == Equal); + THEN("The sum constraint info is correct") { + CHECK(sum_constraint.axis() == std::nullopt); + CHECK(sum_constraint.num_bounds() == 1); + CHECK(sum_constraint.get_bound(0) == 1.0); + CHECK(sum_constraint.num_operators() == 1); + CHECK(sum_constraint.get_operator(0) == Equal); } } - GIVEN("AxisBound(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { - AxisBound bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0}); + GIVEN("SumConstraint(axis = 2, operators = {==, <=, >=}, bounds = {1.0})") { + SumConstraint sum_constraint(2, {Equal, LessEqual, GreaterEqual}, {1.0}); - THEN("The bound axis info is correct") { - CHECK(bound_axis.axis() == 2); - CHECK(bound_axis.num_bounds() == 1); - CHECK(bound_axis.get_bound(0) == 1.0); - CHECK(bound_axis.num_operators() == 3); - CHECK(bound_axis.get_operator(0) == Equal); - CHECK(bound_axis.get_operator(1) == LessEqual); - CHECK(bound_axis.get_operator(2) == GreaterEqual); + THEN("The sum constraint info is correct") { + CHECK(sum_constraint.axis() == 2); + CHECK(sum_constraint.num_bounds() == 1); + CHECK(sum_constraint.get_bound(0) == 1.0); + CHECK(sum_constraint.num_operators() == 3); + CHECK(sum_constraint.get_operator(0) == Equal); + CHECK(sum_constraint.get_operator(1) == LessEqual); + CHECK(sum_constraint.get_operator(2) == GreaterEqual); } } - GIVEN("AxisBound(axis = 2, operators = {==}, bounds = {1.0, 2.0, 3.0})") { - AxisBound bound_axis(2, {Equal}, {1.0, 2.0, 3.0}); + GIVEN("SumConstraint(axis = 2, operators = {==}, bounds = {1.0, 2.0, 3.0})") { + SumConstraint sum_constraint(2, {Equal}, {1.0, 2.0, 3.0}); - THEN("The bound axis info is correct") { - CHECK(bound_axis.axis() == 2); - CHECK(bound_axis.num_bounds() == 3); - CHECK(bound_axis.get_bound(0) == 1.0); - CHECK(bound_axis.get_bound(1) == 2.0); - CHECK(bound_axis.get_bound(2) == 3.0); - CHECK(bound_axis.num_operators() == 1); - CHECK(bound_axis.get_operator(0) == Equal); + THEN("The sum constraint info is correct") { + CHECK(sum_constraint.axis() == 2); + CHECK(sum_constraint.num_bounds() == 3); + CHECK(sum_constraint.get_bound(0) == 1.0); + CHECK(sum_constraint.get_bound(1) == 2.0); + CHECK(sum_constraint.get_bound(2) == 3.0); + CHECK(sum_constraint.num_operators() == 1); + CHECK(sum_constraint.get_operator(0) == Equal); } } - GIVEN("AxisBound(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { - AxisBound bound_axis(2, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}); + GIVEN("SumConstraint(axis = 2, operators = {==, <=, >=}, bounds = {1.0, 2.0, 3.0})") { + SumConstraint sum_constraint(2, {Equal, LessEqual, GreaterEqual}, {1.0, 2.0, 3.0}); - THEN("The bound axis info is correct") { - CHECK(bound_axis.axis() == 2); - CHECK(bound_axis.num_bounds() == 3); - CHECK(bound_axis.get_bound(0) == 1.0); - CHECK(bound_axis.get_bound(1) == 2.0); - CHECK(bound_axis.get_bound(2) == 3.0); - CHECK(bound_axis.num_operators() == 3); - CHECK(bound_axis.get_operator(0) == Equal); - CHECK(bound_axis.get_operator(1) == LessEqual); - CHECK(bound_axis.get_operator(2) == GreaterEqual); + THEN("The sum constraint info is correct") { + CHECK(sum_constraint.axis() == 2); + CHECK(sum_constraint.num_bounds() == 3); + CHECK(sum_constraint.get_bound(0) == 1.0); + CHECK(sum_constraint.get_bound(1) == 2.0); + CHECK(sum_constraint.get_bound(2) == 3.0); + CHECK(sum_constraint.num_operators() == 3); + CHECK(sum_constraint.get_operator(0) == Equal); + CHECK(sum_constraint.get_operator(1) == LessEqual); + CHECK(sum_constraint.get_operator(2) == GreaterEqual); } } } @@ -541,210 +541,230 @@ TEST_CASE("BinaryNode") { "Number array cannot have dynamic size."); } - // *********************** Axis-wise bounds tests ************************* - GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis -1") { - std::vector bound_axes{{-1, {Equal}, {1.0}}}; + // *********************** Sum Constraint tests ************************* + GIVEN("(2x3)-BinaryNode with a sum constraint on the invalid axis -1") { + std::vector sum_constraints{{-1, {Equal}, {1.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, - std::nullopt, std::nullopt, bound_axes), - "Invalid bound axis given number array shape."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid constrained axis given number array shape."); } - GIVEN("(2x3)-BinaryNode with axis-wise bounds on the invalid axis 2") { - std::vector bound_axes{{2, {Equal}, {1.0}}}; + GIVEN("(2x3)-BinaryNode with a sum constraint on the invalid axis 2") { + std::vector sum_constraints{{2, {Equal}, {1.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, - std::nullopt, std::nullopt, bound_axes), - "Invalid bound axis given number array shape."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid constrained axis given number array shape."); } - 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}}}; + GIVEN("(2x3)-BinaryNode with a sum constraint on axis: 1 with too many operators.") { + std::vector sum_constraints{{1, {LessEqual, Equal, Equal, Equal}, {1.0}}}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid number of operators given number array shape."); } - GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector bound_axes{{1, {LessEqual, Equal}, {1.0}}}; + GIVEN("(2x3)-BinaryNode with a sum constraint on axis: 1 with too few operators.") { + std::vector sum_constraints{{1, {LessEqual, Equal}, {1.0}}}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid number of operators given number array shape."); } - 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}}}; + GIVEN("(2x3)-BinaryNode with a sum constraint on axis: 1 with too many bounds.") { + std::vector sum_constraints{{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), - "Invalid number of axis-wise bounds given number array shape."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid number of bounds given number array shape."); } - GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector bound_axes{{1, {LessEqual}, {1.0, 2.0}}}; + GIVEN("(2x3)-BinaryNode with a sum constraint on axis: 1 with too few bounds.") { + std::vector sum_constraints{{1, {LessEqual}, {1.0, 2.0}}}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid number of bounds given number array shape."); } - GIVEN("(6)-BinaryNode with duplicate bounds over the entire array") { - AxisBound bound_axis{std::nullopt, {Equal}, {1.0}}; - std::vector bound_axes{bound_axis, bound_axis}; + GIVEN("(6)-BinaryNode with duplicate sum constraints over the entire array") { + SumConstraint sum_constraint{std::nullopt, {Equal}, {1.0}}; + std::vector sum_constraints{sum_constraint, sum_constraint}; REQUIRE_THROWS_WITH( - graph.emplace_node(6, std::nullopt, std::nullopt, bound_axes), - "Cannot define multiple bounds for the entire array."); + graph.emplace_node(6, std::nullopt, std::nullopt, sum_constraints), + "Cannot define multiple sum constraints for the entire number array."); } - GIVEN("(2x3)-BinaryNode with duplicate axis-wise bounds on axis: 1") { - AxisBound bound_axis{1, {Equal}, {1.0}}; - std::vector bound_axes{bound_axis, bound_axis}; + GIVEN("(2x3)-BinaryNode with duplicate sum constraints on axis: 1") { + SumConstraint sum_constraint{1, {Equal}, {1.0}}; + std::vector sum_constraints{sum_constraint, sum_constraint}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Cannot define multiple sum constraints for a single axis."); } - GIVEN("(2x3)-BinaryNode with axis-wise bounds on axis: 0 and the entire array.") { - AxisBound bound_axis{std::nullopt, {LessEqual}, {1.0}}; - AxisBound bound_axis_1{1, {LessEqual}, {1.0}}; - std::vector bound_axes{bound_axis, bound_axis_1}; + GIVEN("(2x3)-BinaryNode with sum constraints on axis: 0 and the entire array.") { + SumConstraint sum_constraint{std::nullopt, {LessEqual}, {1.0}}; + SumConstraint sum_constraint_1{1, {LessEqual}, {1.0}}; + std::vector sum_constraints{sum_constraint, sum_constraint_1}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Can define at most one sum constraint per number array."); } - GIVEN("(2x3)-BinaryNode with axis-wise bounds on axes: 0 and 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}; + GIVEN("(2x3)-BinaryNode with sum constraints on axes: 0 and 1") { + SumConstraint sum_constraint_0{0, {LessEqual}, {1.0}}; + SumConstraint sum_constraint_1{1, {LessEqual}, {1.0}}; + std::vector sum_constraints{sum_constraint_0, sum_constraint_1}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Can define at most one sum constraint per number array."); } - GIVEN("(2x3x4)-BinaryNode with non-integral axis-wise bounds") { - std::vector bound_axes{{1, {Equal}, {0.1}}}; + GIVEN("(2x3x4)-BinaryNode with a non-integral sum constraint") { + std::vector sum_constraints{{1, {Equal}, {0.1}}}; - 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 integral."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Sum constraint(s) for integral arrays must be integral."); } - GIVEN("(6)-BinaryNode with an infeasible bound over the entire array.") { + GIVEN("(6)-BinaryNode with an infeasible sum constraint over the entire array.") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {Equal}, {7.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{6}, - std::nullopt, std::nullopt, bound_axes), - "Infeasible axis-wise bounds."); + std::vector sum_constraints{{std::nullopt, {Equal}, {7.0}}}; + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{6}, std::nullopt, + std::nullopt, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(3x2)-BinaryNode with an infeasible bound over the entire array.") { + GIVEN("(3x2)-BinaryNode with an infeasible sum constraint over the entire array.") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {GreaterEqual}, {7.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2}, - std::nullopt, std::nullopt, bound_axes), - "Infeasible axis-wise bounds."); + std::vector sum_constraints{{std::nullopt, {GreaterEqual}, {7.0}}}; + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{3, 2}, std::nullopt, + std::nullopt, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(2x2x2)-BinaryNode with an infeasible bound over the entire array.") { + GIVEN("(2x2x2)-BinaryNode with an infeasible sum constraint over the entire array.") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {LessEqual}, {-1.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 2, 2}, - std::nullopt, std::nullopt, bound_axes), - "Infeasible axis-wise bounds."); + std::vector sum_constraints{{std::nullopt, {LessEqual}, {-1.0}}}; + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 2, 2}, + std::nullopt, std::nullopt, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 0") { + GIVEN("(3x2x2)-BinaryNode with an infeasible sum constraint on axis: 0") { auto graph = Graph(); - std::vector bound_axes{{0, {Equal, LessEqual, GreaterEqual}, {5.0, 2.0, 3.0}}}; + std::vector sum_constraints{ + {0, {Equal, LessEqual, GreaterEqual}, {5.0, 2.0, 3.0}}}; // Each slice 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. - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{3, 2, 2}, - std::nullopt, std::nullopt, bound_axes), - "Infeasible axis-wise bounds."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 1") { + GIVEN("(3x2x2)-BinaryNode with an infeasible sum constraint on axis: 1") { auto graph = Graph(); - std::vector bound_axes{{1, {Equal, GreaterEqual}, {5.0, 7.0}}}; + std::vector sum_constraints{{1, {Equal, GreaterEqual}, {5.0, 7.0}}}; // Each slice 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(3x2x2)-BinaryNode with infeasible axis-wise bound on axis: 2") { + GIVEN("(3x2x2)-BinaryNode with an infeasible sum constraint on axis: 2") { auto graph = Graph(); - std::vector bound_axes{{2, {Equal, LessEqual}, {5.0, -1.0}}}; + std::vector sum_constraints{{2, {Equal, LessEqual}, {5.0, -1.0}}}; // Each slice 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{3, 2, 2}, + std::nullopt, std::nullopt, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(6)-BinaryNode with a feasible bound over the entire array.") { + GIVEN("(6)-BinaryNode with a feasible sum constraint over the entire array.") { auto graph = Graph(); std::vector lower_bounds{0, 0, 1, 0, 0, 1}; std::vector upper_bounds{0, 1, 1, 1, 1, 1}; - std::vector bound_axes{{std::nullopt, {Equal}, {3.0}}}; - auto bnode_ptr = graph.emplace_node(6, lower_bounds, upper_bounds, bound_axes); + std::vector sum_constraints{{std::nullopt, {Equal}, {3.0}}}; + auto bnode_ptr = + graph.emplace_node(6, lower_bounds, upper_bounds, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bnode_bound_axis.axis() == std::nullopt); - CHECK(bnode_bound_axis.num_bounds() == 1); - CHECK(bnode_bound_axis.get_bound(0) == 3.0); - CHECK(bnode_bound_axis.num_operators() == 1); - CHECK(bnode_bound_axis.get_operator(0) == Equal); + THEN("Sum constraint is correct") { + CHECK(bnode_ptr->sum_constraint().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_sum_constraint.axis() == std::nullopt); + CHECK(bnode_sum_constraint.num_bounds() == 1); + CHECK(bnode_sum_constraint.get_bound(0) == 3.0); + CHECK(bnode_sum_constraint.num_operators() == 1); + CHECK(bnode_sum_constraint.get_operator(0) == Equal); } WHEN("We create a state by initialize_state()") { auto state = graph.initialize_state(); graph.initialize_state(state); std::vector expected_init{0, 1, 1, 0, 0, 1}; - auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = bnode_ptr->sum_constraint_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() == 1); - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({3})); + THEN("Sum constraint sums and state are correct") { + CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 1); + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({3})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); } } } - GIVEN("(3x2x2)-BinaryNode with feasible axis-wise bound on axis: 0") { + GIVEN("(3x2x2)-BinaryNode with a feasible sum constraint 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 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); - AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bnode_bound_axis.axis() == 0); - CHECK(bnode_bound_axis.num_bounds() == 3); - CHECK(bnode_bound_axis.get_bound(0) == 1.0); - CHECK(bnode_bound_axis.get_bound(1) == 2.0); - CHECK(bnode_bound_axis.get_bound(2) == 3.0); - CHECK(bnode_bound_axis.num_operators() == 3); - CHECK(bnode_bound_axis.get_operator(0) == Equal); - CHECK(bnode_bound_axis.get_operator(1) == LessEqual); - CHECK(bnode_bound_axis.get_operator(2) == GreaterEqual); + std::vector sum_constraints{ + {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, sum_constraints); + + THEN("Sum constraint is correct") { + CHECK(bnode_ptr->sum_constraint().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_sum_constraint.axis() == 0); + CHECK(bnode_sum_constraint.num_bounds() == 3); + CHECK(bnode_sum_constraint.get_bound(0) == 1.0); + CHECK(bnode_sum_constraint.get_bound(1) == 2.0); + CHECK(bnode_sum_constraint.get_bound(2) == 3.0); + CHECK(bnode_sum_constraint.num_operators() == 3); + CHECK(bnode_sum_constraint.get_operator(0) == Equal); + CHECK(bnode_sum_constraint.get_operator(1) == LessEqual); + CHECK(bnode_sum_constraint.get_operator(2) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -759,41 +779,42 @@ TEST_CASE("BinaryNode") { // print(a[2, :, :].flatten()) // >>> [ 8 9 10 11] // - // Cannonically least state that satisfies the index- and axis-wise - // bounds + // Cannonically least state that satisfies the index-wise bounds + // and sum constraints. // 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); + auto sum_constraint_sums = bnode_ptr->sum_constraint_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})); + THEN("Sum constraint sums and state are correct") { + CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->sum_constraint_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") { + GIVEN("(3x2x2)-BinaryNode with a feasible sum constraint 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 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); - AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bnode_bound_axis.axis() == 1); - CHECK(bnode_bound_axis.num_bounds() == 2); - CHECK(bnode_bound_axis.get_bound(0) == 1.0); - CHECK(bnode_bound_axis.get_bound(1) == 5.0); - CHECK(bnode_bound_axis.num_operators() == 2); - CHECK(bnode_bound_axis.get_operator(0) == LessEqual); - CHECK(bnode_bound_axis.get_operator(1) == GreaterEqual); + std::vector sum_constraints{{1, {LessEqual, GreaterEqual}, {1.0, 5.0}}}; + auto bnode_ptr = + graph.emplace_node(std::initializer_list{3, 2, 2}, + lower_bounds, upper_bounds, sum_constraints); + + THEN("Sum constraint is correct") { + CHECK(bnode_ptr->sum_constraint().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_sum_constraint.axis() == 1); + CHECK(bnode_sum_constraint.num_bounds() == 2); + CHECK(bnode_sum_constraint.get_bound(0) == 1.0); + CHECK(bnode_sum_constraint.get_bound(1) == 5.0); + CHECK(bnode_sum_constraint.num_operators() == 2); + CHECK(bnode_sum_constraint.get_operator(0) == LessEqual); + CHECK(bnode_sum_constraint.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -806,42 +827,43 @@ TEST_CASE("BinaryNode") { // print(a[:, 1, :].flatten()) // >>> [ 2 3 6 7 10 11] // - // Cannonically least state that satisfies bounds + // Cannonically least state that satisfies sum constraint // 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); + auto sum_constraint_sums = bnode_ptr->sum_constraint_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})); + THEN("Sum constraint sums and state are correct") { + CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->sum_constraint_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") { + GIVEN("(3x2x2)-BinaryNode with a feasible sum constraint 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 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); - AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bnode_bound_axis.axis() == 2); - CHECK(bnode_bound_axis.num_bounds() == 2); - CHECK(bnode_bound_axis.get_bound(0) == 3.0); - CHECK(bnode_bound_axis.get_bound(1) == 6.0); - CHECK(bnode_bound_axis.num_operators() == 2); - CHECK(bnode_bound_axis.get_operator(0) == Equal); - CHECK(bnode_bound_axis.get_operator(1) == GreaterEqual); + std::vector sum_constraints{{2, {Equal, GreaterEqual}, {3.0, 6.0}}}; + auto bnode_ptr = + graph.emplace_node(std::initializer_list{3, 2, 2}, + lower_bounds, upper_bounds, sum_constraints); + + THEN("Sum constraint is correct") { + CHECK(bnode_ptr->sum_constraint().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_sum_constraint.axis() == 2); + CHECK(bnode_sum_constraint.num_bounds() == 2); + CHECK(bnode_sum_constraint.get_bound(0) == 3.0); + CHECK(bnode_sum_constraint.get_bound(1) == 6.0); + CHECK(bnode_sum_constraint.num_operators() == 2); + CHECK(bnode_sum_constraint.get_operator(0) == Equal); + CHECK(bnode_sum_constraint.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -854,91 +876,95 @@ TEST_CASE("BinaryNode") { // print(a[:, :, 1].flatten()) // >>> [ 1 3 5 7 9 11] // - // Cannonically least state that satisfies the index- and axis-wise - // bounds + // Cannonically least state that satisfies the index-wise bounds + // and sum constraint. // 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); + auto sum_constraint_sums = bnode_ptr->sum_constraint_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})); + THEN("Sum constraint sums and state are correct") { + CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({3, 6})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); } } } - GIVEN("(2)-BinaryNode with a bound over the entire array") { + GIVEN("(2)-BinaryNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {Equal}, {1}}}; - auto bnode_ptr = graph.emplace_node(2, std::nullopt, std::nullopt, bound_axes); + std::vector sum_constraints{{std::nullopt, {Equal}, {1}}}; + auto bnode_ptr = + graph.emplace_node(2, std::nullopt, std::nullopt, sum_constraints); WHEN("We initialize an invalid states") { auto state = graph.empty_state(); std::vector init_values{0, 0}; CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } WHEN("We initialize an invalid states") { auto state = graph.empty_state(); std::vector init_values{1, 1}; CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } } - GIVEN("(2)-BinaryNode with a bound over the entire array") { + GIVEN("(2)-BinaryNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {GreaterEqual}, {1}}}; - auto bnode_ptr = graph.emplace_node(2, std::nullopt, std::nullopt, bound_axes); + std::vector sum_constraints{{std::nullopt, {GreaterEqual}, {1}}}; + auto bnode_ptr = + graph.emplace_node(2, std::nullopt, std::nullopt, sum_constraints); WHEN("We initialize an invalid states") { auto state = graph.empty_state(); std::vector init_values{0, 0}; CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } } - GIVEN("(2)-BinaryNode with a bound over the entire array") { + GIVEN("(2)-BinaryNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {LessEqual}, {1}}}; - auto bnode_ptr = graph.emplace_node(2, std::nullopt, std::nullopt, bound_axes); + std::vector sum_constraints{{std::nullopt, {LessEqual}, {1}}}; + auto bnode_ptr = + graph.emplace_node(2, std::nullopt, std::nullopt, sum_constraints); WHEN("We initialize an invalid states") { auto state = graph.empty_state(); std::vector init_values{1, 1}; CHECK_THROWS_WITH(bnode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } } - GIVEN("(2x2x2)-BinaryNode with a bound over the entire array") { + GIVEN("(2x2x2)-BinaryNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {LessEqual}, {5}}}; - auto bnode_ptr = graph.emplace_node(std::initializer_list{2, 2, 2}, - std::nullopt, std::nullopt, bound_axes); + std::vector sum_constraints{{std::nullopt, {LessEqual}, {5}}}; + auto bnode_ptr = + graph.emplace_node(std::initializer_list{2, 2, 2}, + std::nullopt, std::nullopt, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(bnode_ptr->axis_wise_bounds().size() == 1); - AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bnode_bound_axis.axis() == std::nullopt); - CHECK(bnode_bound_axis.num_bounds() == 1); - CHECK(bnode_bound_axis.get_bound(0) == 5.0); - CHECK(bnode_bound_axis.num_operators() == 1); - CHECK(bnode_bound_axis.get_operator(0) == LessEqual); + THEN("Sum constraint is correct") { + CHECK(bnode_ptr->sum_constraint().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_sum_constraint.axis() == std::nullopt); + CHECK(bnode_sum_constraint.num_bounds() == 1); + CHECK(bnode_sum_constraint.get_bound(0) == 5.0); + CHECK(bnode_sum_constraint.num_operators() == 1); + CHECK(bnode_sum_constraint.get_operator(0) == LessEqual); } 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."); + "Cannot randomly initialize_state with sum constraints."); } WHEN("We initialize a valid state") { @@ -947,12 +973,12 @@ TEST_CASE("BinaryNode") { bnode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = bnode_ptr->sum_constraint_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() == 1); - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({2.0})); + THEN("Sum constraint sums and state are correct") { + CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 1); + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({2.0})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -963,8 +989,8 @@ TEST_CASE("BinaryNode") { std::swap(init_values[2], init_values[3]); // state is now: [0, 0, 1, 0, 1, 0, 0, 0] - THEN("The bound axis sums and state updated correctly") { - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({2.0})); + THEN("Sum constraint sums and state updated correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({2.0})); CHECK(bnode_ptr->diff(state).size() == 2); // 2 updates per exchange CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -972,8 +998,8 @@ TEST_CASE("BinaryNode") { AND_WHEN("We revert") { graph.revert(state); - THEN("The bound axis sums reverted correctly") { - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({2.0})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({2.0})); CHECK(bnode_ptr->diff(state).size() == 0); } } @@ -981,31 +1007,33 @@ TEST_CASE("BinaryNode") { } } - GIVEN("(3x2x2)-BinaryNode with an axis-wise bound on axis: 0") { + GIVEN("(3x2x2)-BinaryNode with a sum constraint on axis: 0") { auto graph = Graph(); - 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); - AxisBound bnode_bound_axis = bnode_ptr->axis_wise_bounds()[0]; - CHECK(bnode_bound_axis.axis() == 0); - CHECK(bnode_bound_axis.num_bounds() == 3); - CHECK(bnode_bound_axis.get_bound(0) == 1.0); - CHECK(bnode_bound_axis.get_bound(1) == 2.0); - CHECK(bnode_bound_axis.get_bound(2) == 3.0); - CHECK(bnode_bound_axis.num_operators() == 3); - CHECK(bnode_bound_axis.get_operator(0) == Equal); - CHECK(bnode_bound_axis.get_operator(1) == LessEqual); - CHECK(bnode_bound_axis.get_operator(2) == GreaterEqual); + std::vector sum_constraints{ + {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, sum_constraints); + + THEN("Sum constraint is correct") { + CHECK(bnode_ptr->sum_constraint().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_sum_constraint.axis() == 0); + CHECK(bnode_sum_constraint.num_bounds() == 3); + CHECK(bnode_sum_constraint.get_bound(0) == 1.0); + CHECK(bnode_sum_constraint.get_bound(1) == 2.0); + CHECK(bnode_sum_constraint.get_bound(2) == 3.0); + CHECK(bnode_sum_constraint.num_operators() == 3); + CHECK(bnode_sum_constraint.get_operator(0) == Equal); + CHECK(bnode_sum_constraint.get_operator(1) == LessEqual); + CHECK(bnode_sum_constraint.get_operator(2) == GreaterEqual); } 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."); + "Cannot randomly initialize_state with sum constraints."); } WHEN("We initialize three invalid states") { @@ -1018,7 +1046,7 @@ TEST_CASE("BinaryNode") { // 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."); + "Initialized values do not satisfy sum constraint(s)."); state = graph.empty_state(); // This state violates the slice 1 along axis 0 @@ -1029,7 +1057,7 @@ TEST_CASE("BinaryNode") { // 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."); + "Initialized values do not satisfy sum constraint(s)."); state = graph.empty_state(); // This state violates the slice 2 along axis 0 @@ -1040,7 +1068,7 @@ TEST_CASE("BinaryNode") { // 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."); + "Initialized values do not satisfy sum constraint(s)."); } WHEN("We initialize a valid state") { @@ -1049,18 +1077,18 @@ TEST_CASE("BinaryNode") { bnode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto bound_axis_sums = bnode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { + THEN("Sum constraint 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)) // >>> 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})); + CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 2, 4})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1073,13 +1101,13 @@ TEST_CASE("BinaryNode") { 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") { + THEN("Sum constraint 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)) // >>> array([1, 2, 4]) - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK_THAT(bnode_ptr->sum_constraint_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)); } @@ -1087,8 +1115,9 @@ TEST_CASE("BinaryNode") { 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})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } } @@ -1108,7 +1137,7 @@ TEST_CASE("BinaryNode") { 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") { + THEN("Sum constraint 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 @@ -1117,7 +1146,7 @@ TEST_CASE("BinaryNode") { // 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_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 4); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1125,8 +1154,9 @@ TEST_CASE("BinaryNode") { 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})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } } @@ -1147,7 +1177,7 @@ TEST_CASE("BinaryNode") { 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") { + THEN("Sum constraint 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 @@ -1157,7 +1187,7 @@ TEST_CASE("BinaryNode") { // 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_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 4); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1165,8 +1195,9 @@ TEST_CASE("BinaryNode") { 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})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } } @@ -1181,14 +1212,14 @@ TEST_CASE("BinaryNode") { 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") { + THEN("Sum constraint 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)) // >>> array([1, 2, 3]) - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 2, 3})); + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 2, 3})); CHECK(bnode_ptr->diff(state).size() == 3); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1196,8 +1227,9 @@ TEST_CASE("BinaryNode") { 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})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } } @@ -1212,14 +1244,14 @@ TEST_CASE("BinaryNode") { 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") { + THEN("Sum constraint 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)) // >>> array([1, 1, 3]) - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 2); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1233,8 +1265,9 @@ TEST_CASE("BinaryNode") { 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})); + THEN("sum constraint sums updated correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + RangeEquals({1, 1, 4})); CHECK(bnode_ptr->diff(state).size() == 1); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1242,8 +1275,8 @@ TEST_CASE("BinaryNode") { AND_WHEN("We revert") { graph.revert(state); - THEN("The bound axis sums reverted correctly") { - CHECK_THAT(bnode_ptr->bound_axis_sums(state)[0], + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 0); } @@ -1252,7 +1285,7 @@ TEST_CASE("BinaryNode") { } } } - // *********************** Axis-wise bounds tests ************************* + // *********************** Sum Constraint tests ************************* } TEST_CASE("IntegerNode") { @@ -1552,197 +1585,210 @@ TEST_CASE("IntegerNode") { "Number array cannot have dynamic size."); } - // *********************** Axis-wise bounds tests ************************* - GIVEN("(2x3)-IntegerNode with axis-wise bounds on the invalid axis -2") { - std::vector bound_axes{{-2, {Equal}, {20.0}}}; + // *********************** Sum Constraint tests ************************* + GIVEN("(2x3)-IntegerNode with a sum constraint on the invalid axis -2") { + std::vector sum_constraints{{-2, {Equal}, {20.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{2, 3}, - std::nullopt, std::nullopt, bound_axes), - "Invalid bound axis given number array shape."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3}, std::nullopt, + std::nullopt, sum_constraints), + "Invalid constrained axis given number array shape."); } - GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on the invalid axis 3") { - std::vector bound_axes{{3, {Equal}, {10.0}}}; + GIVEN("(2x3x4)-IntegerNode with a sum constraint on the invalid axis 3") { + std::vector sum_constraints{{3, {Equal}, {10.0}}}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Invalid constrained axis given number array shape."); } - 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}}}; + GIVEN("(2x3x4)-IntegerNode with a sum constraint on axis: 1 with too many operators.") { + std::vector sum_constraints{{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), - "Invalid number of axis-wise operators given number array shape."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Invalid number of operators given number array shape."); } - GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few operators.") { - std::vector bound_axes{{1, {LessEqual, Equal}, {-11.0}}}; + GIVEN("(2x3x4)-IntegerNode with a sum constraint on axis: 1 with too few operators.") { + std::vector sum_constraints{{1, {LessEqual, Equal}, {-11.0}}}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Invalid number of operators given number array shape."); } - 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}}}; + GIVEN("(2x3x4)-IntegerNode with a sum constraint on axis: 1 with too many bounds.") { + std::vector sum_constraints{{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), - "Invalid number of axis-wise bounds given number array shape."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Invalid number of bounds given number array shape."); } - GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 with too few bounds.") { - std::vector bound_axes{{1, {LessEqual}, {111.0, -223.0}}}; + GIVEN("(2x3x4)-IntegerNode with a sum constraint on axis: 1 with too few bounds.") { + std::vector sum_constraints{{1, {LessEqual}, {111.0, -223.0}}}; - 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."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Invalid number of bounds given number array shape."); } - GIVEN("(6)-IntegerNode with duplicate bounds over the entire array") { - AxisBound bound_axis{std::nullopt, {Equal}, {10.0}}; - std::vector bound_axes{bound_axis, bound_axis}; + GIVEN("(6)-IntegerNode with duplicate sum constraints over the entire array") { + SumConstraint sum_constraint{std::nullopt, {Equal}, {10.0}}; + std::vector sum_constraints{sum_constraint, sum_constraint}; - REQUIRE_THROWS_WITH(graph.emplace_node(std::initializer_list{6}, - std::nullopt, std::nullopt, bound_axes), - "Cannot define multiple bounds for the entire array."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{6}, std::nullopt, + std::nullopt, sum_constraints), + "Cannot define multiple sum constraints for the entire number array."); } - GIVEN("(2x3x4)-IntegerNode with duplicate axis-wise bounds on axis: 1") { - std::vector bound_axes{{1, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; + GIVEN("(2x3x4)-IntegerNode with duplicate sum constraints on axis: 1") { + std::vector sum_constraints{{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), - "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, sum_constraints), + "Cannot define multiple sum constraints for a single axis."); } - GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axis: 1 and the entire array.") { - std::vector bound_axes{{std::nullopt, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; + GIVEN("(2x3x4)-IntegerNode with sum constraints on axis: 1 and the entire array.") { + std::vector sum_constraints{{std::nullopt, {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), - "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, sum_constraints), + "Can define at most one sum constraint per number array."); } - GIVEN("(2x3x4)-IntegerNode with axis-wise bounds on axes: 0 and 1") { - std::vector bound_axes{{0, {Equal}, {100.0}}, {1, {Equal}, {100.0}}}; + GIVEN("(2x3x4)-IntegerNode with sum constraints on axes: 0 and 1") { + std::vector sum_constraints{{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), - "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, sum_constraints), + "Can define at most one sum constraint per number array."); } - GIVEN("(2x3x4)-IntegerNode with non-integral axis-wise bounds") { - std::vector bound_axes{{1, {LessEqual}, {11.0, 12.0001, 0.0}}}; + GIVEN("(2x3x4)-IntegerNode with a non-integral sum constraint") { + std::vector sum_constraints{{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), - "Axis wise bounds for integral number arrays must be integral."); + REQUIRE_THROWS_WITH( + graph.emplace_node(std::initializer_list{2, 3, 4}, + std::nullopt, std::nullopt, sum_constraints), + "Sum constraint(s) for integral arrays must be integral."); } - GIVEN("(6)-IntegerNode with an infeasible bound over the entire array.") { + GIVEN("(6)-IntegerNode with an infeasible sum constraint over the entire array.") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {Equal}, {-7.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 8, bound_axes), - "Infeasible axis-wise bounds."); + std::vector sum_constraints{{std::nullopt, {Equal}, {-7.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 8, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(6)-IntegerNode with an infeasible bound over the entire array.") { + GIVEN("(6)-IntegerNode with an infeasible sum constraint over the entire array.") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {LessEqual}, {-7.0}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 8, bound_axes), - "Infeasible axis-wise bounds."); + std::vector sum_constraints{{std::nullopt, {LessEqual}, {-7.0}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 8, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(6)-IntegerNode with an infeasible bound over the entire array.") { + GIVEN("(6)-IntegerNode with an infeasible sum constraint over the entire array.") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {GreaterEqual}, {13}}}; - REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 2, bound_axes), - "Infeasible axis-wise bounds."); + std::vector sum_constraints{{std::nullopt, {GreaterEqual}, {13}}}; + REQUIRE_THROWS_WITH(graph.emplace_node(6, -1, 2, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 0") { + GIVEN("(2x3x2)-IntegerNode with an infeasible sum constraint on axis: 0") { auto graph = Graph(); - std::vector bound_axes{{0, {Equal, LessEqual}, {5.0, -31.0}}}; + std::vector sum_constraints{{0, {Equal, LessEqual}, {5.0, -31.0}}}; // Each slice 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."); + -5, 8, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 1") { + GIVEN("(2x3x2)-IntegerNode with an infeasible sum constraint on axis: 1") { auto graph = Graph(); - std::vector bound_axes{{1, {GreaterEqual, Equal, Equal}, {33.0, 0.0, 0.0}}}; + std::vector sum_constraints{ + {1, {GreaterEqual, Equal, Equal}, {33.0, 0.0, 0.0}}}; // Each slice 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."); + -5, 8, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(2x3x2)-IntegerNode with infeasible axis-wise bound on axis: 2") { + GIVEN("(2x3x2)-IntegerNode with an infeasible sum constraint on axis: 2") { auto graph = Graph(); - std::vector bound_axes{{2, {GreaterEqual, Equal}, {-1.0, 49.0}}}; + std::vector sum_constraints{{2, {GreaterEqual, Equal}, {-1.0, 49.0}}}; // Each slice 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."); + -5, 8, sum_constraints), + "Infeasible sum constraint."); } - GIVEN("(2x2x2)-IntegerNode with a feasible bound over the entire array ") { + GIVEN("(2x2x2)-IntegerNode with a feasible sum constraint over the entire array ") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {GreaterEqual}, {40}}}; + std::vector sum_constraints{{std::nullopt, {GreaterEqual}, {40}}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 2, 2}, - -5, 8, bound_axes); + -5, 8, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(inode_ptr->axis_wise_bounds().size() == 1); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == std::nullopt); - CHECK(inode_bound_axis.num_bounds() == 1); - CHECK(inode_bound_axis.get_bound(0) == 40.0); - CHECK(inode_bound_axis.num_operators() == 1); - CHECK(inode_bound_axis.get_operator(0) == GreaterEqual); + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == std::nullopt); + CHECK(inode_sum_constraint.num_bounds() == 1); + CHECK(inode_sum_constraint.get_bound(0) == 40.0); + CHECK(inode_sum_constraint.num_operators() == 1); + CHECK(inode_sum_constraint.get_operator(0) == GreaterEqual); } WHEN("We create a state by initialize_state()") { auto state = graph.initialize_state(); graph.initialize_state(state); std::vector expected_init{8, 8, 8, 8, 8, 8, -3, -5}; - auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { - CHECK(inode_ptr->bound_axis_sums(state).size() == 1); - CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 1); - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({40})); + THEN("Sum constraint sums and state are correct") { + CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 1); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({40})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } - GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 0") { + GIVEN("(2x3x2)-IntegerNode with a feasible sum constraint on axis: 0") { auto graph = Graph(); - std::vector bound_axes{{0, {Equal, GreaterEqual}, {-21.0, 9.0}}}; + std::vector sum_constraints{{0, {Equal, GreaterEqual}, {-21.0, 9.0}}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, - -5, 8, bound_axes); + -5, 8, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(inode_ptr->axis_wise_bounds().size() == 1); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == 0); - CHECK(inode_bound_axis.num_bounds() == 2); - CHECK(inode_bound_axis.get_bound(0) == -21.0); - CHECK(inode_bound_axis.get_bound(1) == 9.0); - CHECK(inode_bound_axis.num_operators() == 2); - CHECK(inode_bound_axis.get_operator(0) == Equal); - CHECK(inode_bound_axis.get_operator(1) == GreaterEqual); + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == 0); + CHECK(inode_sum_constraint.num_bounds() == 2); + CHECK(inode_sum_constraint.get_bound(0) == -21.0); + CHECK(inode_sum_constraint.get_bound(1) == 9.0); + CHECK(inode_sum_constraint.num_operators() == 2); + CHECK(inode_sum_constraint.get_operator(0) == Equal); + CHECK(inode_sum_constraint.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -1755,7 +1801,7 @@ TEST_CASE("IntegerNode") { // print(a[1, :, :].flatten()) // >>> [ 6 7 8 9 10 11] // - // The method `construct_state_given_exactly_one_bound_axis()` + // The method `construct_state_given_exactly_one_sum_constraint()` // will construct a state as follows: // [-5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5] // repair slice 0 @@ -1763,35 +1809,36 @@ TEST_CASE("IntegerNode") { // 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 = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { - CHECK(inode_ptr->bound_axis_sums(state).size() == 1); - CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 2); - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({-21.0, 9.0})); + THEN("Sum constraint sums and state are correct") { + CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({-21.0, 9.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } - GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 1") { + GIVEN("(2x3x2)-IntegerNode with a feasible sum constraint on axis: 1") { auto graph = Graph(); - std::vector bound_axes{{1, {Equal, GreaterEqual, LessEqual}, {0.0, -2.0, 0.0}}}; + std::vector sum_constraints{ + {1, {Equal, GreaterEqual, LessEqual}, {0.0, -2.0, 0.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); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == 1); - CHECK(inode_bound_axis.num_bounds() == 3); - CHECK(inode_bound_axis.get_bound(0) == 0.0); - CHECK(inode_bound_axis.get_bound(1) == -2.0); - CHECK(inode_bound_axis.get_bound(2) == 0.0); - CHECK(inode_bound_axis.num_operators() == 3); - CHECK(inode_bound_axis.get_operator(0) == Equal); - CHECK(inode_bound_axis.get_operator(1) == GreaterEqual); - CHECK(inode_bound_axis.get_operator(2) == LessEqual); + -5, 8, sum_constraints); + + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == 1); + CHECK(inode_sum_constraint.num_bounds() == 3); + CHECK(inode_sum_constraint.get_bound(0) == 0.0); + CHECK(inode_sum_constraint.get_bound(1) == -2.0); + CHECK(inode_sum_constraint.get_bound(2) == 0.0); + CHECK(inode_sum_constraint.num_operators() == 3); + CHECK(inode_sum_constraint.get_operator(0) == Equal); + CHECK(inode_sum_constraint.get_operator(1) == GreaterEqual); + CHECK(inode_sum_constraint.get_operator(2) == LessEqual); } WHEN("We create a state by initialize_state()") { @@ -1806,7 +1853,7 @@ TEST_CASE("IntegerNode") { // print(a[:, 2, :].flatten()) // >>> [ 4 5 10 11] // - // The method `construct_state_given_exactly_one_bound_axis()` + // The method `construct_state_given_exactly_one_sum_constraint()` // 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] @@ -1815,33 +1862,34 @@ TEST_CASE("IntegerNode") { // [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 = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { - 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({0.0, -2.0, -20.0})); + THEN("Sum constraint sums and state are correct") { + CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 3); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], + RangeEquals({0.0, -2.0, -20.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } - GIVEN("(2x3x2)-IntegerNode with feasible axis-wise bound on axis: 2") { + GIVEN("(2x3x2)-IntegerNode with a feasible sum constraint on axis: 2") { auto graph = Graph(); - std::vector bound_axes{{2, {Equal, GreaterEqual}, {23.0, 14.0}}}; + std::vector sum_constraints{{2, {Equal, GreaterEqual}, {23.0, 14.0}}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3, 2}, - -5, 8, bound_axes); + -5, 8, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(inode_ptr->axis_wise_bounds().size() == 1); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == 2); - CHECK(inode_bound_axis.num_bounds() == 2); - CHECK(inode_bound_axis.get_bound(0) == 23.0); - CHECK(inode_bound_axis.get_bound(1) == 14.0); - CHECK(inode_bound_axis.num_operators() == 2); - CHECK(inode_bound_axis.get_operator(0) == Equal); - CHECK(inode_bound_axis.get_operator(1) == GreaterEqual); + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == 2); + CHECK(inode_sum_constraint.num_bounds() == 2); + CHECK(inode_sum_constraint.get_bound(0) == 23.0); + CHECK(inode_sum_constraint.get_bound(1) == 14.0); + CHECK(inode_sum_constraint.num_operators() == 2); + CHECK(inode_sum_constraint.get_operator(0) == Equal); + CHECK(inode_sum_constraint.get_operator(1) == GreaterEqual); } WHEN("We create a state by initialize_state()") { @@ -1854,7 +1902,7 @@ TEST_CASE("IntegerNode") { // print(a[:, :, 1].flatten()) // >>> [ 1 3 5 7 9 11] // - // The method `construct_state_given_exactly_one_bound_axis()` + // The method `construct_state_given_exactly_one_sum_constraint()` // 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] @@ -1862,82 +1910,82 @@ TEST_CASE("IntegerNode") { // 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 = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { - CHECK(inode_ptr->bound_axis_sums(state).size() == 1); - CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 2); - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({23.0, 14.0})); + THEN("Sum constraint sums and state are correct") { + CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({23.0, 14.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } } - GIVEN("(2)-IntegerNode with a bound over the entire array") { + GIVEN("(2)-IntegerNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {Equal}, {15}}}; - auto inode_ptr = graph.emplace_node(2, -5, 8, bound_axes); + std::vector sum_constraints{{std::nullopt, {Equal}, {15}}}; + auto inode_ptr = graph.emplace_node(2, -5, 8, sum_constraints); WHEN("We initialize two invalid states") { auto state = graph.empty_state(); std::vector init_values{0.0, 0.0}; CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); state = graph.empty_state(); init_values = {8.0, 8.0}; CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } } - GIVEN("(2)-IntegerNode with a bound over the entire array") { + GIVEN("(2)-IntegerNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {LessEqual}, {10}}}; - auto inode_ptr = graph.emplace_node(2, -5, 8, bound_axes); + std::vector sum_constraints{{std::nullopt, {LessEqual}, {10}}}; + auto inode_ptr = graph.emplace_node(2, -5, 8, sum_constraints); WHEN("We initialize an invalid states") { auto state = graph.empty_state(); std::vector init_values{8.0, 7.0}; CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } } - GIVEN("(2)-IntegerNode with a bound over the entire array") { + GIVEN("(2)-IntegerNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {GreaterEqual}, {10}}}; - auto inode_ptr = graph.emplace_node(2, -5, 8, bound_axes); + std::vector sum_constraints{{std::nullopt, {GreaterEqual}, {10}}}; + auto inode_ptr = graph.emplace_node(2, -5, 8, sum_constraints); WHEN("We initialize an invalid states") { auto state = graph.empty_state(); std::vector init_values{-5.0, -4.0}; CHECK_THROWS_WITH(inode_ptr->initialize_state(state, init_values), - "Initialized values do not satisfy axis-wise bounds."); + "Initialized values do not satisfy sum constraint(s)."); } } - GIVEN("(2x2)-BinaryNode with a bound over the entire array") { + GIVEN("(2x2)-BinaryNode with a sum constraint over the entire array") { auto graph = Graph(); - std::vector bound_axes{{std::nullopt, {GreaterEqual}, {5.0}}}; + std::vector sum_constraints{{std::nullopt, {GreaterEqual}, {5.0}}}; auto inode_ptr = graph.emplace_node(std::initializer_list{2, 2}, -5, - 8, bound_axes); + 8, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(inode_ptr->axis_wise_bounds().size() == 1); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == std::nullopt); - CHECK(inode_bound_axis.num_bounds() == 1); - CHECK(inode_bound_axis.get_bound(0) == 5.0); - CHECK(inode_bound_axis.num_operators() == 1); - CHECK(inode_bound_axis.get_operator(0) == GreaterEqual); + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == std::nullopt); + CHECK(inode_sum_constraint.num_bounds() == 1); + CHECK(inode_sum_constraint.get_bound(0) == 5.0); + CHECK(inode_sum_constraint.num_operators() == 1); + CHECK(inode_sum_constraint.get_operator(0) == GreaterEqual); } 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."); + "Cannot randomly initialize_state with sum constraints."); } WHEN("We initialize a valid state") { @@ -1946,12 +1994,12 @@ TEST_CASE("IntegerNode") { inode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { - CHECK(inode_ptr->bound_axis_sums(state).size() == 1); - CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 1); - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({5.0})); + THEN("Sum constraint sums and state are correct") { + CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 1); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({5.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -1962,8 +2010,8 @@ TEST_CASE("IntegerNode") { init_values[2] = 3; // state is now: [1.0, -1.0, 3.0, 5.0] - THEN("The bound axis sums and state updated correctly") { - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({8.0})); + THEN("Sum constraint sums and state updated correctly") { + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({8.0})); CHECK(inode_ptr->diff(state).size() == 1); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -1971,8 +2019,8 @@ TEST_CASE("IntegerNode") { AND_WHEN("We revert") { graph.revert(state); - THEN("The bound axis sums reverted correctly") { - CHECK_THAT(bound_axis_sums[0], RangeEquals({5.0})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(sum_constraint_sums[0], RangeEquals({5.0})); CHECK(inode_ptr->diff(state).size() == 0); } } @@ -1980,31 +2028,32 @@ TEST_CASE("IntegerNode") { } } - GIVEN("(2x3x2)-IntegerNode with index-wise bounds and an axis-wise bound on axis: 1") { + GIVEN("(2x3x2)-IntegerNode with index-wise bounds and a sum constraint on axis: 1") { auto graph = Graph(); - std::vector bound_axes{{1, {Equal, LessEqual, GreaterEqual}, {11.0, 2.0, 5.0}}}; + std::vector sum_constraints{ + {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); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == 1); - CHECK(inode_bound_axis.num_bounds() == 3); - CHECK(inode_bound_axis.get_bound(0) == 11.0); - CHECK(inode_bound_axis.get_bound(1) == 2.0); - CHECK(inode_bound_axis.get_bound(2) == 5.0); - CHECK(inode_bound_axis.num_operators() == 3); - CHECK(inode_bound_axis.get_operator(0) == Equal); - CHECK(inode_bound_axis.get_operator(1) == LessEqual); - CHECK(inode_bound_axis.get_operator(2) == GreaterEqual); + -5, 8, sum_constraints); + + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == 1); + CHECK(inode_sum_constraint.num_bounds() == 3); + CHECK(inode_sum_constraint.get_bound(0) == 11.0); + CHECK(inode_sum_constraint.get_bound(1) == 2.0); + CHECK(inode_sum_constraint.get_bound(2) == 5.0); + CHECK(inode_sum_constraint.num_operators() == 3); + CHECK(inode_sum_constraint.get_operator(0) == Equal); + CHECK(inode_sum_constraint.get_operator(1) == LessEqual); + CHECK(inode_sum_constraint.get_operator(2) == GreaterEqual); } 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."); + "Cannot randomly initialize_state with sum constraints."); } WHEN("We initialize three invalid states") { @@ -2017,7 +2066,7 @@ TEST_CASE("IntegerNode") { // 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."); + "Initialized values do not satisfy sum constraint(s)."); state = graph.empty_state(); // This state violates the slice 1 along axis 1 @@ -2028,7 +2077,7 @@ TEST_CASE("IntegerNode") { // 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."); + "Initialized values do not satisfy sum constraint(s)."); state = graph.empty_state(); // This state violates the slice 2 along axis 1 @@ -2039,7 +2088,7 @@ TEST_CASE("IntegerNode") { // 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."); + "Initialized values do not satisfy sum constraint(s)."); } WHEN("We initialize a valid state") { @@ -2048,18 +2097,18 @@ TEST_CASE("IntegerNode") { inode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { + THEN("Sum constraint 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(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 3); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, 2, 7})); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2074,7 +2123,7 @@ TEST_CASE("IntegerNode") { 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") { + THEN("Sum constraint 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 @@ -2082,7 +2131,7 @@ TEST_CASE("IntegerNode") { // 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_THAT(inode_ptr->sum_constraint_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)); } @@ -2090,8 +2139,9 @@ TEST_CASE("IntegerNode") { 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})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], + RangeEquals({11, 2, 7})); CHECK(inode_ptr->diff(state).size() == 0); } } @@ -2105,13 +2155,13 @@ TEST_CASE("IntegerNode") { 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") { + THEN("Sum constraint 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)) // >>> array([11, -5, 15]) - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({11, -5, 15})); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, -5, 15})); CHECK(inode_ptr->diff(state).size() == 2); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2119,8 +2169,9 @@ TEST_CASE("IntegerNode") { 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})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], + RangeEquals({11, 2, 7})); CHECK(inode_ptr->diff(state).size() == 0); } } @@ -2139,7 +2190,7 @@ TEST_CASE("IntegerNode") { 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") { + THEN("Sum constraint 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 @@ -2148,7 +2199,7 @@ TEST_CASE("IntegerNode") { // 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_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, 1, 9})); CHECK(inode_ptr->diff(state).size() == 4); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2156,8 +2207,8 @@ TEST_CASE("IntegerNode") { AND_WHEN("We revert") { graph.revert(state); - THEN("The bound axis sums reverted correctly") { - CHECK_THAT(bound_axis_sums[0], RangeEquals({11, 2, 7})); + THEN("Sum constraint sums reverted correctly") { + CHECK_THAT(sum_constraint_sums[0], RangeEquals({11, 2, 7})); CHECK(inode_ptr->diff(state).size() == 0); } } @@ -2165,32 +2216,32 @@ TEST_CASE("IntegerNode") { } } - GIVEN("(2x3)-IntegerNode and an axis-wise bound on axis: 0 with operator `==`") { + GIVEN("(2x3)-IntegerNode and a sum constraint on axis: 0 with operator `==`") { auto graph = Graph(); - std::vector bound_axes{{0, {Equal}, {1.0}}}; - auto inode_ptr = graph.emplace_node(std::initializer_list{2, 3}, - std::nullopt, std::nullopt, bound_axes); + std::vector sum_constraints{{0, {Equal}, {1.0}}}; + auto inode_ptr = graph.emplace_node( + std::initializer_list{2, 3}, std::nullopt, std::nullopt, sum_constraints); - THEN("Axis wise bound is correct") { - CHECK(inode_ptr->axis_wise_bounds().size() == 1); - AxisBound inode_bound_axis = inode_ptr->axis_wise_bounds()[0]; - CHECK(inode_bound_axis.axis() == 0); - CHECK(inode_bound_axis.num_bounds() == 1); - CHECK(inode_bound_axis.get_bound(0) == 1.0); - CHECK(inode_bound_axis.num_operators() == 1); - CHECK(inode_bound_axis.get_operator(0) == Equal); + THEN("Sum constraint is correct") { + CHECK(inode_ptr->sum_constraint().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_sum_constraint.axis() == 0); + CHECK(inode_sum_constraint.num_bounds() == 1); + CHECK(inode_sum_constraint.get_bound(0) == 1.0); + CHECK(inode_sum_constraint.num_operators() == 1); + CHECK(inode_sum_constraint.get_operator(0) == Equal); } WHEN("We initialize a valid state by construction") { auto state = graph.empty_state(); graph.initialize_state(state); - auto bound_axis_sums = inode_ptr->bound_axis_sums(state); + auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); - THEN("The bound axis sums and state are correct") { - CHECK(inode_ptr->bound_axis_sums(state).size() == 1); - CHECK(inode_ptr->bound_axis_sums(state).data()[0].size() == 2); - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({1.0, 1.0})); + THEN("Sum constraint sums and state are correct") { + CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); + CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({1.0, 1.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals({1, 0, 0, 1, 0, 0})); } @@ -2198,15 +2249,15 @@ TEST_CASE("IntegerNode") { inode_ptr->exchange(state, 0, 1); inode_ptr->exchange(state, 3, 4); - THEN("The bound axis sums and state updated correctly") { - CHECK_THAT(inode_ptr->bound_axis_sums(state)[0], RangeEquals({1.0, 1.0})); + THEN("Sum constraint sums and state updated correctly") { + CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({1.0, 1.0})); CHECK(inode_ptr->diff(state).size() == 4); // 2 updates per exchange CHECK_THAT(inode_ptr->view(state), RangeEquals({0, 1, 0, 0, 1, 0})); } } } } - // *********************** Axis-wise bounds tests ************************* + // *********************** Sum Constraint tests ************************* } } // namespace dwave::optimization From 2510a80bdca320d68b0f87324a865b052e073db8 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 6 Mar 2026 15:02:28 -0800 Subject: [PATCH 27/31] Changed `NumberNode` bound axis naming convention at Python level See prior commit message. --- .../dwave-optimization/nodes/numbers.hpp | 72 ++--- dwave/optimization/libcpp/nodes/numbers.pxd | 10 +- dwave/optimization/model.py | 101 +++--- dwave/optimization/src/nodes/numbers.cpp | 288 +++++++++--------- dwave/optimization/symbols/numbers.pyx | 222 ++++++++------ tests/cpp/nodes/test_numbers.cpp | 200 ++++++------ tests/test_symbols.py | 222 +++++++------- 7 files changed, 585 insertions(+), 530 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index cfd5f524..0e653481 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -118,7 +118,7 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { template void initialize_state(State& state, Generator& rng) const { // Currently do not support random node initialization with sum constraints. - if (sum_constraint_.size() > 0) { + if (sum_constraints_.size() > 0) { throw std::invalid_argument("Cannot randomly initialize_state with sum constraints."); } @@ -163,19 +163,19 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // in a given index. void clip_and_set_value(State& state, ssize_t index, double value) const; - /// Return the stateless sum constraint information i.e. sum_constraint_. - const std::vector& sum_constraint() const; + /// Return the stateless sum constraints. + const std::vector& sum_constraints() const; - /// If the node is subject to sum constraint(s), we track the state + /// If the node is subject to sum constraints, we track the state /// dependent sum of the values within each slice per constraint. The /// returned vector is indexed in the same ordering as the constraints /// given by `sum_constraints()`. - const std::vector>& sum_constraint_sums(const State& state) const; + const std::vector>& sum_constraints_lhs(const State& state) const; protected: explicit NumberNode(std::span shape, std::vector lower_bound, std::vector upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); // Return truth statement: 'value is valid in a given index'. virtual bool is_valid(ssize_t index, double value) const = 0; @@ -183,9 +183,9 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Default value in a given index. virtual double default_value(ssize_t index) const = 0; - /// Update the relevant sum constraint sums given that the value stored at - /// `index` is changed by `value_change` in a given state. - void update_sum_constraint_sums(State& state, const ssize_t index, + /// Update the relevant sum constraints running sums (`lhs`) given that the + /// value stored at `index` is changed by `value_change` in a given state. + void update_sum_constraints_lhs(State& state, const ssize_t index, const double value_change) const; /// Statelss global minimum and maximum of the values stored in NumberNode. @@ -196,10 +196,10 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { std::vector lower_bounds_; std::vector upper_bounds_; - /// Stateless information on each sum constraint. - std::vector sum_constraint_; + /// Stateless sum constraints. + std::vector sum_constraints_; /// Indicator variable that all sum constraint operators are "==". - bool sum_constraint_all_equals_; + bool sum_constraints_all_equals_; }; /// A contiguous block of integer numbers. @@ -219,39 +219,39 @@ class IntegerNode : public NumberNode { IntegerNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(std::span shape, std::optional> lower_bound, - double upper_bound, std::vector sum_constraint = {}); + double upper_bound, std::vector sum_constraints = {}); IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); // Overloads needed by the Node ABC *************************************** @@ -287,38 +287,38 @@ class BinaryNode : public IntegerNode { BinaryNode(std::span shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(ssize_t size, std::optional> lower_bound = std::nullopt, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound = std::nullopt, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(std::span shape, std::optional> lower_bound, - double upper_bound, std::vector sum_constraint = {}); + double upper_bound, std::vector sum_constraints = {}); BinaryNode(std::initializer_list shape, std::optional> lower_bound, - double upper_bound, std::vector sum_constraint = {}); + double upper_bound, std::vector sum_constraints = {}); BinaryNode(ssize_t size, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::vector sum_constraint = {}); + std::vector sum_constraints = {}); // 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 4ed9ae2e..da37c98e 100644 --- a/dwave/optimization/libcpp/nodes/numbers.pxd +++ b/dwave/optimization/libcpp/nodes/numbers.pxd @@ -22,16 +22,16 @@ from dwave.optimization.libcpp.state cimport State cdef extern from "dwave-optimization/nodes/numbers.hpp" namespace "dwave::optimization" nogil: cdef cppclass NumberNode(ArrayNode): - struct AxisBound: + struct SumConstraint: # It appears Cython automatically assumes all (standard) enums are "public". # Because of this, we use this very explict override. - enum class Operator "dwave::optimization::NumberNode::AxisBound::Operator": + enum class Operator "dwave::optimization::NumberNode::SumConstraint::Operator": Equal LessEqual GreaterEqual - AxisBound(optional[Py_ssize_t] axis, vector[Operator] axis_operators, - vector[double] axis_bounds) + SumConstraint(optional[Py_ssize_t] axis, vector[Operator] operators, + vector[double] bounds) optional[Py_ssize_t] axis() double get_bound(Py_ssize_t slice) @@ -44,7 +44,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[AxisBound] axis_wise_bounds() + const vector[SumConstraint] sum_constraints() cdef cppclass IntegerNode(NumberNode): pass diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index 78402dac..f0011839 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -166,8 +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 | list[tuple[int, str | list[str], float | list[float]] | - tuple[str | list[str], float | list[float]]] = None + subject_to: None | list[tuple[str, float]] = None, + axes_subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = None ) -> BinaryVariable: r"""Create a binary symbol as a decision variable. @@ -181,19 +181,29 @@ 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 applied to the symbol. Must be an - array of tuples where each tuple has the form: (axis, operators, bounds) - or (operators, bounds). - - axis (optional int): The axis along which the bounds are applied. If - not axis is provided, the bound will be applied to the entire array. - - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). - 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 slices; an array specifies one bound per slice. + subject_to (optional): Constraint on the sum of the values in the + array. Must be an array of tuples where each tuple has the form: + (operator, bound). + - operator (str): The constraint operator ("<=", "==", or ">="). + - bound (float): The constraint bound. + If provided, the sum of values within the array must satisfy + the corresponding operator–bound pair. + Note 1: At most one sum constraint may be provided. + Note 2: If provided, axes_subject_to must None. + axes_subject_to (optional): Constraint on the sum of the values in + each slice along a fixed axis in the array. Must be an array of + tuples where each tuple has the form: (axis, operator(s), bound(s)). + - axis (int): The axis that the constraint is applied to. + - operator(s) (str | array[str]): The constraint operator(s) + ("<=", "==", or ">="). A single operator applies to all slice + along the axis; an array specifies one operator per slice. + - bound(s) (float | array[float]): The constraint bound. A + single value 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. + specified axis must satisfy the corresponding operator–bound pair. + Note 1: At most one sum constraint may be provided. + Note 2: If provided, subject_to must None. Returns: A binary symbol. @@ -232,7 +242,7 @@ def binary(self, shape: None | _ShapeLike = None, True This example adds a :math:`(2x3)`-sized binary symbol with - index-wise lower bounds and an axis-wise bound along axis 1. Let + index-wise lower bounds and a sum constraint along axis 1. Let x_i (int i : 0 <= i <= 2) denote the sum of the values within slice i along axis 1. For each state defined for this symbol: (x_0 <= 0), (x_1 == 2), and (x_2 >= 1). @@ -241,8 +251,8 @@ def binary(self, shape: None | _ShapeLike = None, >>> import numpy as np >>> model = Model() >>> b = model.binary([2, 3], lower_bound=[[0, 1, 1], [0, 1, 0]], - ... subject_to=[(1, ["<=", "==", ">="], [0, 2, 1])]) - >>> np.all(b.axis_wise_bounds() == [(1, ["<=", "==", ">="], [0, 2, 1])]) + ... axes_subject_to=[(1, ["<=", "==", ">="], [0, 2, 1])]) + >>> np.all(b.sum_constraints() == [(1, ["<=", "==", ">="], [0, 2, 1])]) True This example adds a :math:`6`-sized binary symbol such that @@ -252,7 +262,7 @@ def binary(self, shape: None | _ShapeLike = None, >>> import numpy as np >>> model = Model() >>> b = model.binary(6, subject_to=[("==", 2)]) - >>> np.all(b.axis_wise_bounds() == [(["=="], [2])]) + >>> np.all(b.sum_constraints() == [(["=="], [2])]) True See Also: @@ -264,11 +274,11 @@ def binary(self, shape: None | _ShapeLike = None, supported. .. versionchanged:: 0.6.12 - Beginning in version 0.6.12, user-defined axis-wise bounds are + Beginning in version 0.6.12, user-defined sum constraints are supported. """ from dwave.optimization.symbols import BinaryVariable # avoid circular import - return BinaryVariable(self, shape, lower_bound, upper_bound, subject_to) + return BinaryVariable(self, shape, lower_bound, upper_bound, subject_to, axes_subject_to) def constant(self, array_like: numpy.typing.ArrayLike) -> Constant: r"""Create a constant symbol. @@ -532,8 +542,8 @@ def integer( shape: None | _ShapeLike = None, lower_bound: None | numpy.typing.ArrayLike = None, upper_bound: None | numpy.typing.ArrayLike = None, - subject_to: None | list[tuple[int, str | list[str], float | list[float]] | - tuple[str | list[str], float | list[float]]] = None + subject_to: None | list[tuple[str, float]] = None, + axes_subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = None ) -> IntegerVariable: r"""Create an integer symbol as a decision variable. @@ -547,20 +557,29 @@ 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 applied to the symbol. Must be an - array of tuples where each tuple has the form: (axis, operators, bounds) - or (operators, bounds). - - axis (optional int): The axis along which the bounds are applied. If - not axis is provided, the bound will be applied to the entire array. - - operators (str | array[str]): The operator(s) ("<=", "==", or ">="). - 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 slices; an array specifies one bound per slice. + subject_to (optional): Constraint on the sum of the values in the + array. Must be an array of tuples where each tuple has the form: + (operator, bound). + - operator (str): The constraint operator ("<=", "==", or ">="). + - bound (float): The constraint bound. + If provided, the sum of values within the array must satisfy + the corresponding operator–bound pair. + Note 1: At most one sum constraint may be provided. + Note 2: If provided, axes_subject_to must None. + axes_subject_to (optional): Constraint on the sum of the values in + each slice along a fixed axis in the array. Must be an array of + tuples where each tuple has the form: (axis, operator(s), bound(s)). + - axis (int): The axis that the constraint is applied to. + - operator(s) (str | array[str]): The constraint operator(s) + ("<=", "==", or ">="). A single operator applies to all slice + along the axis; an array specifies one operator per slice. + - bound(s) (float | array[float]): The constraint bound. A + single value 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. - + specified axis must satisfy the corresponding operator–bound pair. + Note 1: At most one sum constraint may be provided. + Note 2: If provided, subject_to must None. Returns: An integer symbol. @@ -599,7 +618,7 @@ def integer( True 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 + lower and upper bounds and a sum constraint along axis 1. Let x_i (int i : 0 <= i <= 2) denote the sum of the values within slice i along axis 1. For each state defined for this symbol: (x_0 <= 2), (x_1 <= 4), and (x_2 <= 5). @@ -608,8 +627,8 @@ def integer( >>> import numpy as np >>> model = Model() >>> 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])]) + ... axes_subject_to=[(1, "<=", [2, 4, 5])]) + >>> np.all(i.sum_constraints() == [(1, ["<="], [2, 4, 5])]) True This example adds a :math:`6`-sized integer symbol such that @@ -620,7 +639,7 @@ def integer( >>> import numpy as np >>> model = Model() >>> i = model.integer(6, subject_to=[("<=", 20)]) - >>> np.all(i.axis_wise_bounds() == [(["<="], [20])]) + >>> np.all(i.sum_constraints() == [(["<="], [20])]) True See Also: @@ -631,11 +650,11 @@ def integer( supported. .. versionchanged:: 0.6.12 - Beginning in version 0.6.12, user-defined axis-wise bounds are + Beginning in version 0.6.12, user-defined sum constraints are supported. """ from dwave.optimization.symbols import IntegerVariable # avoid circular import - return IntegerVariable(self, shape, lower_bound, upper_bound, subject_to) + return IntegerVariable(self, shape, lower_bound, upper_bound, subject_to, axes_subject_to) def list(self, n: int, diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 3d4f6f8b..3bce4002 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -75,24 +75,24 @@ struct NumberNodeStateData : public ArrayNodeStateData { NumberNodeStateData(std::vector input) : ArrayNodeStateData(std::move(input)) {} // User provides sum constraints. NumberNodeStateData(std::vector input, - std::vector> sum_constraint_sums) + std::vector> sum_constraints_lhs) : ArrayNodeStateData(std::move(input)), - sum_constraint_sums(std::move(sum_constraint_sums)), - prior_sum_constraint_sums(this->sum_constraint_sums) {} + sum_constraints_lhs(std::move(sum_constraints_lhs)), + prior_sum_constraints_lhs(this->sum_constraints_lhs) {} std::unique_ptr copy() const override { return std::make_unique(*this); } /// For each sum constraint, track the sum of the values within each slice. - /// `sum_constraint_sums[i][j]` is the sum of the values within the `j`th slice + /// `sum_constraints_lhs[i][j]` is the sum of the values within the `j`th slice /// along the `axis`* defined by the `i`th sum constraint. /// /// (*) If `axis == std::nullopt`, the constraint is applied to the entire /// array, which is treated as a flat array with a single slice. - std::vector> sum_constraint_sums; + std::vector> sum_constraints_lhs; // Store a copy for NumberNode::revert() and commit() - std::vector> prior_sum_constraint_sums; + std::vector> prior_sum_constraints_lhs; }; double const* NumberNode::buff(const State& state) const noexcept { @@ -113,32 +113,32 @@ double NumberNode::max() const { return max_; } /// /// (*) If `axis == std::nullopt`, the constraint is applied to the entire /// array, which is treated as a flat array with a single slice. -std::vector> get_sum_constraint_sums(const NumberNode* node, +std::vector> get_sum_constraints_lhs(const NumberNode* node, const std::vector& number_data) { std::span node_shape = node->shape(); - const auto& sum_constraint = node->sum_constraint(); - const ssize_t num_sum_constraints = static_cast(sum_constraint.size()); + const auto& sum_constraints = node->sum_constraints(); + const ssize_t num_sum_constraints = static_cast(sum_constraints.size()); assert(num_sum_constraints <= static_cast(node_shape.size())); assert(std::accumulate(node_shape.begin(), node_shape.end(), 1, std::multiplies()) == static_cast(number_data.size())); // For each sum constraint, initialize the sum of the values contained in // each of its slice to 0. - std::vector> sum_constraint_sums; - sum_constraint_sums.reserve(num_sum_constraints); - for (const NumberNode::SumConstraint& constraint : sum_constraint) { + std::vector> sum_constraints_lhs; + sum_constraints_lhs.reserve(num_sum_constraints); + for (const NumberNode::SumConstraint& constraint : sum_constraints) { const std::optional axis = constraint.axis(); // Handle the case where the sum constraint applies to the entire array. if (!axis.has_value()) { // Array is treated as a flat array with a single axis. - sum_constraint_sums.emplace_back(1, 0.0); + sum_constraints_lhs.emplace_back(1, 0.0); continue; } assert(axis.has_value()); assert(0 <= *axis && *axis < static_cast(node_shape.size())); // Emplace an all zeros vector of size equal to the number of slice // along the given constrained axis. - sum_constraint_sums.emplace_back(node_shape[*axis], 0.0); + sum_constraints_lhs.emplace_back(node_shape[*axis], 0.0); } // Define a BufferIterator for `number_data` given the shape and strides of @@ -147,46 +147,46 @@ std::vector> get_sum_constraint_sums(const NumberNode* node, it != std::default_sentinel; ++it) { // Increment the sum of the appropriate slice per sum constraint. for (ssize_t i = 0; i < num_sum_constraints; ++i) { - const std::optional axis = sum_constraint[i].axis(); + const std::optional axis = sum_constraints[i].axis(); // Handle the case where the sum constraint applies to the entire array. if (!axis.has_value()) { - assert(sum_constraint_sums[i].size() == 1); - sum_constraint_sums[i].front() += *it; + assert(sum_constraints_lhs[i].size() == 1); + sum_constraints_lhs[i].front() += *it; continue; } assert(axis.has_value()); assert(0 <= *axis && *axis < static_cast(it.location().size())); const ssize_t slice = it.location()[*axis]; assert(0 <= slice); - assert(slice < static_cast(sum_constraint_sums[i].size())); - sum_constraint_sums[i][slice] += *it; + assert(slice < static_cast(sum_constraints_lhs[i].size())); + sum_constraints_lhs[i][slice] += *it; } } - return sum_constraint_sums; + return sum_constraints_lhs; } /// Determine whether the sum constraints are satisfied. -bool satisfies_sum_constraint(const std::vector& sum_constraint, - const std::vector>& sum_constraint_sums) { - assert(sum_constraint.size() == sum_constraint_sums.size()); +bool satisfies_sum_constraint(const std::vector& sum_constraints, + const std::vector>& sum_constraints_lhs) { + assert(sum_constraints.size() == sum_constraints_lhs.size()); // Iterate over each sum constraint. - for (ssize_t i = 0, stop_i = static_cast(sum_constraint.size()); i < stop_i; ++i) { - const auto& constraint = sum_constraint[i]; - const auto& contraint_sums = sum_constraint_sums[i]; + for (ssize_t i = 0, stop_i = static_cast(sum_constraints.size()); i < stop_i; ++i) { + const auto& constraint = sum_constraints[i]; + const auto& lhs = sum_constraints_lhs[i]; // Return `false` if any slice does not satisfy the constraint. - for (ssize_t slice = 0, stop_slice = static_cast(contraint_sums.size()); - slice < stop_slice; ++slice) { + for (ssize_t slice = 0, stop_slice = static_cast(lhs.size()); slice < stop_slice; + ++slice) { switch (constraint.get_operator(slice)) { case NumberNode::SumConstraint::Operator::Equal: - if (contraint_sums[slice] != constraint.get_bound(slice)) return false; + if (lhs[slice] != constraint.get_bound(slice)) return false; break; case NumberNode::SumConstraint::Operator::LessEqual: - if (contraint_sums[slice] > constraint.get_bound(slice)) return false; + if (lhs[slice] > constraint.get_bound(slice)) return false; break; case NumberNode::SumConstraint::Operator::GreaterEqual: - if (contraint_sums[slice] < constraint.get_bound(slice)) return false; + if (lhs[slice] < constraint.get_bound(slice)) return false; break; default: assert(false && "Unexpected operator type."); @@ -208,19 +208,19 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } } - if (sum_constraint_.size() == 0) { // No sum constraints to consider. + if (sum_constraints_.size() == 0) { // No sum constraints to consider. emplace_data_ptr(state, std::move(number_data)); } else { // Given the assignment to NumberNode `number_data`, compute the sum // of the values within each slice per sum constraint. - auto sum_constraint_sums = get_sum_constraint_sums(this, number_data); + auto sum_constraints_lhs = get_sum_constraints_lhs(this, number_data); - if (!satisfies_sum_constraint(sum_constraint_, sum_constraint_sums)) { + if (!satisfies_sum_constraint(sum_constraints_, sum_constraints_lhs)) { throw std::invalid_argument("Initialized values do not satisfy sum constraint(s)."); } emplace_data_ptr(state, std::move(number_data), - std::move(sum_constraint_sums)); + std::move(sum_constraints_lhs)); } } @@ -254,27 +254,27 @@ std::vector undo_shift_axis_data(const std::span span, c return output; } -/// Given a `sum`, operator (`op`), and a `bound`, determine the non-negative amount -/// `delta` needed to be added to `sum` to satisfy the constraint: (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 +/// Given a `lhs`, operator (`op`), and a `bound`, determine the non-negative amount +/// `delta` needed to be added to `lhs` to satisfy the constraint: (lhs+delta) op bound. +/// e.g. Given (lhs, op, bound) := (10, ==, 12), delta = 2 +/// e.g. Given (lhs, op, bound) := (10, <=, 12), delta = 0 +/// e.g. Given (lhs, op, bound) := (10, >=, 12), delta = 2 /// Throws an error if `delta` is negative (corresponding with an infeasible sum constraint) -double sum_constraint_delta(const double sum, const NumberNode::SumConstraint::Operator op, +double sum_constraint_delta(const double lhs, const NumberNode::SumConstraint::Operator op, const double bound) { switch (op) { case NumberNode::SumConstraint::Operator::Equal: - if (sum > bound) throw std::invalid_argument("Infeasible sum constraint."); + if (lhs > bound) throw std::invalid_argument("Infeasible sum constraint."); // If error was not thrown, return amount needed to satisfy constraint. - return bound - sum; + return bound - lhs; case NumberNode::SumConstraint::Operator::LessEqual: - if (sum > bound) throw std::invalid_argument("Infeasible sum constraint."); + if (lhs > bound) throw std::invalid_argument("Infeasible sum constraint."); // If error was not thrown, sum satisfies constraint. return 0.0; case NumberNode::SumConstraint::Operator::GreaterEqual: // If sum is less than bound, return the amount needed to equal it. // Otherwise, sum satisfies constraint. - return (sum < bound) ? (bound - sum) : 0.0; + return (lhs < bound) ? (bound - lhs) : 0.0; default: assert(false && "Unexpected operator type."); unreachable(); @@ -297,17 +297,17 @@ void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, } // 2) Determine the slice sums for the sum constraint. To improve performance, // compute sum during previous loop. - assert(node->sum_constraint().size() == 1); - const std::vector constraint_sums = get_sum_constraint_sums(node, values).front(); + assert(node->sum_constraints().size() == 1); + const std::vector lhs = get_sum_constraints_lhs(node, values).front(); // Obtain the stateless sum constraint information. - const NumberNode::SumConstraint& constraint = node->sum_constraint().front(); + const NumberNode::SumConstraint& constraint = node->sum_constraints().front(); const std::optional axis = constraint.axis(); // Handle the case where the constraint applies to the entire array. if (!axis.has_value()) { - assert(constraint_sums.size() == 1); + assert(lhs.size() == 1); // Determine the amount needed to adjust the values within the array. - double delta = sum_constraint_delta(constraint_sums.front(), constraint.get_operator(0), + double delta = sum_constraint_delta(lhs.front(), constraint.get_operator(0), constraint.get_bound(0)); if (delta == 0) return; // Bound is satisfied for entire array. @@ -346,7 +346,7 @@ void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, // sum constraint. for (ssize_t slice = 0, stop = node_shape[*axis]; slice < stop; ++slice) { // Determine the amount needed to adjust the values within the slice. - double delta = sum_constraint_delta(constraint_sums[slice], constraint.get_operator(slice), + double delta = sum_constraint_delta(lhs[slice], constraint.get_operator(slice), constraint.get_bound(slice)); if (delta == 0) continue; // Sum constraint is satisfied for slice. assert(delta >= 0); // Should only increment. @@ -383,13 +383,13 @@ void NumberNode::initialize_state(State& state) const { std::vector values; values.reserve(this->size()); - if (sum_constraint_.size() == 0) { + if (sum_constraints_.size() == 0) { // No sum constraint to consider, initialize by default. for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { values.push_back(default_value(i)); } initialize_state(state, std::move(values)); - } else if (sum_constraint_.size() == 1) { + } else if (sum_constraints_.size() == 1) { construct_state_given_exactly_one_sum_constraint(this, values); initialize_state(state, std::move(values)); } else { @@ -400,7 +400,7 @@ void NumberNode::initialize_state(State& state) const { void NumberNode::propagate(State& state) const { // Should only propagate states that obey the sum constraint(s). - assert(satisfies_sum_constraint(sum_constraint_, sum_constraint_sums(state))); + assert(satisfies_sum_constraint(sum_constraints_, sum_constraints_lhs(state))); // Technically vestigial but will keep it for forms sake. for (const auto& sv : successors()) { sv->update(state, sv.index); @@ -409,15 +409,15 @@ void NumberNode::propagate(State& state) const { void NumberNode::commit(State& state) const noexcept { auto node_data = data_ptr(state); - // Manually store a copy of sum_constraint_sums. - node_data->prior_sum_constraint_sums = node_data->sum_constraint_sums; + // Manually store a copy of sum_constraints_lhs. + node_data->prior_sum_constraints_lhs = node_data->sum_constraints_lhs; node_data->commit(); } void NumberNode::revert(State& state) const noexcept { auto node_data = data_ptr(state); - // Manually reset sum_constraint_sums. - node_data->sum_constraint_sums = node_data->prior_sum_constraint_sums; + // Manually reset sum_constraints_lhs. + node_data->sum_constraints_lhs = node_data->prior_sum_constraints_lhs; node_data->revert(); } @@ -433,12 +433,12 @@ void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { if (ptr->exchange(i, j)) { // If change occurred and sum constraint exist, update running sums. // Nothing to update if all sum constraints are Equals. - if (!sum_constraint_all_equals_ && sum_constraint_.size() > 0) { + if (!sum_constraints_all_equals_ && sum_constraints_.size() > 0) { const double difference = ptr->get(i) - ptr->get(j); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - update_sum_constraint_sums(state, i, difference); + update_sum_constraints_lhs(state, i, difference); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - update_sum_constraint_sums(state, j, -difference); + update_sum_constraints_lhs(state, j, -difference); } } } @@ -488,18 +488,18 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c // State change occurs IFF `value` != buffer[index]. if (ptr->set(index, value)) { // If change occurred and sum constraint exist, update running sums. - if (sum_constraint_.size() > 0) { - update_sum_constraint_sums(state, index, value - diff(state).back().old); + if (sum_constraints_.size() > 0) { + update_sum_constraints_lhs(state, index, value - diff(state).back().old); } } } -const std::vector& NumberNode::sum_constraint() const { - return sum_constraint_; +const std::vector& NumberNode::sum_constraints() const { + return sum_constraints_; } -const std::vector>& NumberNode::sum_constraint_sums(const State& state) const { - return data_ptr(state)->sum_constraint_sums; +const std::vector>& NumberNode::sum_constraints_lhs(const State& state) const { + return data_ptr(state)->sum_constraints_lhs; } template @@ -515,8 +515,8 @@ double get_extreme_index_wise_bound(const std::vector& bound) { } bool all_sum_constraint_operators_are_equals( - std::vector& sum_constraint) { - for (const NumberNode::SumConstraint& constraint : sum_constraint) { + std::vector& sum_constraints) { + for (const NumberNode::SumConstraint& constraint : sum_constraints) { for (ssize_t i = 0, stop = constraint.num_operators(); i < stop; ++i) { if (constraint.get_operator(i) != NumberNode::SumConstraint::Operator::Equal) return false; @@ -556,8 +556,8 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& /// Check the user defined sum constraint(s). void check_sum_constraints(const NumberNode* node) { - const std::vector& sum_constraint = node->sum_constraint(); - if (sum_constraint.size() == 0) return; // No sum constraints to check. + const std::vector& sum_constraints = node->sum_constraints(); + if (sum_constraints.size() == 0) return; // No sum constraints to check. const std::span shape = node->shape(); // Used to assess if an axis is subject to multiple constraints. @@ -565,7 +565,7 @@ void check_sum_constraints(const NumberNode* node) { // Used to assess if array is subject to multiple constraints. bool constrained_array = false; - for (const NumberNode::SumConstraint& constraint : sum_constraint) { + for (const NumberNode::SumConstraint& constraint : sum_constraints) { const std::optional axis = constraint.axis(); const ssize_t num_operators = static_cast(constraint.num_operators()); const ssize_t num_bounds = static_cast(constraint.num_bounds()); @@ -607,7 +607,7 @@ void check_sum_constraints(const NumberNode* node) { } // *Currently*, we only support one sum constraint. - if (sum_constraint.size() > 1) { + if (sum_constraints.size() > 1) { throw std::invalid_argument("Can define at most one sum constraint per number array."); } @@ -620,14 +620,14 @@ void check_sum_constraints(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 sum_constraint) + std::vector upper_bound, std::vector sum_constraints) : 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)), - sum_constraint_(std::move(sum_constraint)), - sum_constraint_all_equals_(all_sum_constraint_operators_are_equals(sum_constraint_)) { + sum_constraints_(std::move(sum_constraints)), + sum_constraints_all_equals_(all_sum_constraint_operators_are_equals(sum_constraints_)) { if ((shape.size() > 0) && (shape[0] < 0)) { throw std::invalid_argument("Number array cannot have dynamic size."); } @@ -640,36 +640,36 @@ NumberNode::NumberNode(std::span shape, std::vector lower check_sum_constraints(this); } -void NumberNode::update_sum_constraint_sums(State& state, const ssize_t index, +void NumberNode::update_sum_constraints_lhs(State& state, const ssize_t index, const double value_change) const { - const auto& sum_constraint = this->sum_constraint(); - assert(value_change != 0); // Should not call when no change occurs. - assert(sum_constraint.size() != 0); // Should only call where applicable. + const auto& sum_constraints = this->sum_constraints(); + assert(value_change != 0); // Should not call when no change occurs. + assert(sum_constraints.size() != 0); // Should only call where applicable. // Get multidimensional indices for `index` so we can identify the slices // `index` lies on per sum constraint. const std::vector multi_index = unravel_index(index, this->shape()); - assert(sum_constraint.size() <= multi_index.size()); + assert(sum_constraints.size() <= multi_index.size()); // Get the slice sums for all sum constraints. - auto& sum_constraint_sums = data_ptr(state)->sum_constraint_sums; - assert(sum_constraint.size() == sum_constraint_sums.size()); + auto& sum_constraints_lhs = data_ptr(state)->sum_constraints_lhs; + assert(sum_constraints.size() == sum_constraints_lhs.size()); // For each sum constraint. - for (ssize_t i = 0, stop = static_cast(sum_constraint.size()); i < stop; ++i) { - const std::optional axis = sum_constraint[i].axis(); + for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { + const std::optional axis = sum_constraints[i].axis(); // Handle the case where the constraint applies to the entire array. if (!axis.has_value()) { - assert(sum_constraint_sums[i].size() == 1); - sum_constraint_sums[i].front() += value_change; + assert(sum_constraints_lhs[i].size() == 1); + sum_constraints_lhs[i].front() += value_change; continue; } assert(axis.has_value() && 0 <= *axis && *axis < static_cast(multi_index.size())); // Get the slice along the constrained axis the `value_change` occurs in. const ssize_t slice = multi_index[*axis]; - assert(0 <= slice && slice < static_cast(sum_constraint_sums[i].size())); - sum_constraint_sums[i][slice] += value_change; // Offset slice sum. + assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); + sum_constraints_lhs[i][slice] += value_change; // Offset slice sum. } } @@ -677,10 +677,10 @@ void NumberNode::update_sum_constraint_sums(State& state, const ssize_t index, /// Check the user defined sum constraint for IntegerNode. void check_sum_constraint_integrality( - const std::vector& sum_constraint) { - if (sum_constraint.size() == 0) return; // No sum constraints to check. + const std::vector& sum_constraints) { + if (sum_constraints.size() == 0) return; // No sum constraints to check. - for (const NumberNode::SumConstraint& constraint : sum_constraint) { + for (const NumberNode::SumConstraint& constraint : sum_constraints) { for (ssize_t slice = 0, stop = constraint.num_bounds(); slice < stop; ++slice) { const double bound = constraint.get_bound(slice); if (bound != std::floor(bound)) { @@ -694,14 +694,14 @@ void check_sum_constraint_integrality( IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : 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}, - (check_sum_constraint_integrality(sum_constraint), std::move(sum_constraint))) { + (check_sum_constraint_integrality(sum_constraints), std::move(sum_constraints))) { if (min_ < minimum_lower_bound || max_ > maximum_upper_bound) { throw std::invalid_argument("range provided for integers exceeds supported range"); } @@ -710,58 +710,58 @@ IntegerNode::IntegerNode(std::span shape, IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode({size}, std::move(lower_bound), std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(shape, std::vector{lower_bound}, std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode({size}, std::vector{lower_bound}, std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(shape, std::move(lower_bound), std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::vector sum_constraint) + double upper_bound, std::vector sum_constraints) : IntegerNode({size}, std::move(lower_bound), std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(std::span shape, double lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} IntegerNode::IntegerNode(std::initializer_list shape, double lower_bound, - double upper_bound, std::vector sum_constraint) + double upper_bound, std::vector sum_constraints) : IntegerNode(std::span(shape), std::vector{lower_bound}, - std::vector{upper_bound}, std::move(sum_constraint)) {} + std::vector{upper_bound}, std::move(sum_constraints)) {} IntegerNode::IntegerNode(ssize_t size, double lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} bool IntegerNode::integral() const { return true; } @@ -780,8 +780,8 @@ void IntegerNode::set_value(State& state, ssize_t index, double value) const { // State change occurs IFF `value` != buffer[index]. if (ptr->set(index, value)) { // If change occurred and sum constraint exist, update running sums. - if (sum_constraint_.size() > 0) { - update_sum_constraint_sums(state, index, value - diff(state).back().old); + if (sum_constraints_.size() > 0) { + update_sum_constraints_lhs(state, index, value - diff(state).back().old); } } } @@ -823,65 +823,65 @@ std::vector limit_bound_to_bool_domain(std::optional BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : IntegerNode(shape, limit_bound_to_bool_domain(lower_bound), - limit_bound_to_bool_domain(upper_bound), std::move(sum_constraint)) {} + limit_bound_to_bool_domain(upper_bound), std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(std::span(shape), std::move(lower_bound), std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode({size}, std::move(lower_bound), std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(shape, std::vector{lower_bound}, std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(std::span(shape), std::vector{lower_bound}, std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, std::optional> upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode({size}, std::vector{lower_bound}, std::move(upper_bound), - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::span shape, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(shape, std::move(lower_bound), std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::initializer_list shape, std::optional> lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(std::span(shape), std::move(lower_bound), std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(ssize_t size, std::optional> lower_bound, - double upper_bound, std::vector sum_constraint) + double upper_bound, std::vector sum_constraints) : BinaryNode({size}, std::move(lower_bound), std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::span shape, double lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(shape, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} BinaryNode::BinaryNode(std::initializer_list shape, double lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode(std::span(shape), std::vector{lower_bound}, - std::vector{upper_bound}, std::move(sum_constraint)) {} + std::vector{upper_bound}, std::move(sum_constraints)) {} BinaryNode::BinaryNode(ssize_t size, double lower_bound, double upper_bound, - std::vector sum_constraint) + std::vector sum_constraints) : BinaryNode({size}, std::vector{lower_bound}, std::vector{upper_bound}, - std::move(sum_constraint)) {} + std::move(sum_constraints)) {} void BinaryNode::flip(State& state, ssize_t i) const { auto ptr = data_ptr(state); @@ -891,10 +891,10 @@ void BinaryNode::flip(State& state, ssize_t i) const { // State change occurs IFF `value` != buffer[i]. if (ptr->set(i, !ptr->get(i))) { // If change occurred and sum constraint exist, update running sums. - if (sum_constraint_.size() > 0) { + if (sum_constraints_.size() > 0) { // If value changed from 0 -> 1, update by 1. // If value changed from 1 -> 0, update by -1. - update_sum_constraint_sums(state, i, (ptr->get(i) == 1) ? 1 : -1); + update_sum_constraints_lhs(state, i, (ptr->get(i) == 1) ? 1 : -1); } } } diff --git a/dwave/optimization/symbols/numbers.pyx b/dwave/optimization/symbols/numbers.pyx index ec4ee3d3..a8b9d9ac 100644 --- a/dwave/optimization/symbols/numbers.pyx +++ b/dwave/optimization/symbols/numbers.pyx @@ -37,84 +37,92 @@ from dwave.optimization.states cimport States # Convert the str operators "==", "<=", ">=" into their corresponding # C++ objects. -cdef NumberNode.AxisBound.Operator _parse_python_operator(str op) except *: +cdef NumberNode.SumConstraint.Operator _parse_python_operator(str op) except *: if op == "==": - return NumberNode.AxisBound.Operator.Equal + return NumberNode.SumConstraint.Operator.Equal elif op == "<=": - return NumberNode.AxisBound.Operator.LessEqual + return NumberNode.SumConstraint.Operator.LessEqual elif op == ">=": - return NumberNode.AxisBound.Operator.GreaterEqual + return NumberNode.SumConstraint.Operator.GreaterEqual else: - raise TypeError(f"Invalid bound axis operator: {op!r}") + raise TypeError(f"Invalid sum constraint operator: {op!r}") -# Convert the user-defined axis-wise bounds for NumberNode into the +# Convert the user-defined sum constraints for NumberNode into the # corresponding C++ objects passed to NumberNode. -cdef vector[NumberNode.AxisBound] _convert_python_bound_axes( - bound_axes_data : None | list[tuple[int, str | list[str], float | list[float]] | - tuple[str | list[str], float | list[float]]]) except *: - cdef vector[NumberNode.AxisBound] output - - if bound_axes_data is None: - return output - - output.reserve(len(bound_axes_data)) +cdef vector[NumberNode.SumConstraint] _convert_python_sum_constraints( + subject_to: None | list[tuple[str, float]], + axes_subject_to: None | list[tuple[int, str | list[str], float | list[float]]]) except *: + cdef vector[NumberNode.SumConstraint] output cdef optional[Py_ssize_t] cpp_axis = nullopt - cdef vector[NumberNode.AxisBound.Operator] cpp_ops + cdef vector[NumberNode.SumConstraint.Operator] 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) not in [2, 3]: - raise TypeError("Each bound axis entry must be a tuple with two or " - "three elements: axis (optional), operator(s), " - "bound(s)") + if subject_to is not None: + for constraint in subject_to: + if not isinstance(constraint, tuple) or len(constraint) != 2: + raise TypeError("A sum constraint on an entire number array must be" + " a tuple with two elements: `operator` and `bound`") - if len(bound_axis_data) == 2: - py_ops, py_bounds = bound_axis_data + py_ops, py_bounds = constraint cpp_axis = nullopt - else: - axis, py_ops, py_bounds = bound_axis_data - if not isinstance(axis, int): - raise TypeError("Bound axis must be an int or None.") - cpp_axis = axis + if not isinstance(py_ops, str): + raise TypeError("Sum constraint operator on entire number array should be a str.") - if isinstance(py_ops, str): cpp_ops.resize(1) - # One operator defined for all slices. + cpp_bounds.resize(1) cpp_ops[0] = _parse_python_operator(py_ops) - elif isinstance(py_ops, collections.abc.Iterable): - # Operator defined per slice. - 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 an iterable" - " of str(s).") - - 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]) - 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.") + cpp_bounds[0] = py_bounds + output.push_back(NumberNode.SumConstraint(cpp_axis, move(cpp_ops), move(cpp_bounds))) + + if axes_subject_to is not None: + for axis_constraint in axes_subject_to: + if not isinstance(axis_constraint, tuple) or len(axis_constraint) != 3: + raise TypeError("Each axis sum constraint must be a tuple with " + "three elements: axis, operator(s), bound(s)") + + axis, py_ops, py_bounds = axis_constraint + if not isinstance(axis, int): + raise TypeError("Constrained axis must be an int or None.") + cpp_axis = axis + + if isinstance(py_ops, str): + cpp_ops.resize(1) + # One operator defined for all slices. + cpp_ops[0] = _parse_python_operator(py_ops) + elif isinstance(py_ops, collections.abc.Iterable): + # Operator defined per slice. + cpp_ops.reserve(len(py_ops)) + for op in py_ops: + cpp_ops.push_back(_parse_python_operator(op)) + else: + raise TypeError("Axis sum constraint operator(s) should be str or an" + " iterable of str(s).") + + 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]) + for i in range(mem.shape[0]): + cpp_bounds.push_back(mem[i]) + else: + raise TypeError("Axis sum constraint bound(s) should be scalar or 1D-array.") - output.push_back(NumberNode.AxisBound(cpp_axis, move(cpp_ops), move(cpp_bounds))) + output.push_back(NumberNode.SumConstraint(cpp_axis, move(cpp_ops), move(cpp_bounds))) return output # Convert the C++ operators into their corresponding str -cdef str _parse_cpp_operators(NumberNode.AxisBound.Operator op): - if op == NumberNode.AxisBound.Operator.Equal: +cdef str _parse_cpp_operators(NumberNode.SumConstraint.Operator op): + if op == NumberNode.SumConstraint.Operator.Equal: return "==" - elif op == NumberNode.AxisBound.Operator.LessEqual: + elif op == NumberNode.SumConstraint.Operator.LessEqual: return "<=" - elif op == NumberNode.AxisBound.Operator.GreaterEqual: + elif op == NumberNode.SumConstraint.Operator.GreaterEqual: return ">=" else: - raise ValueError(f"Invalid bound axis operator: {op!r}") + raise TypeError(f"Invalid sum constraint operator: {op!r}") cdef class BinaryVariable(ArraySymbol): @@ -125,15 +133,15 @@ cdef class BinaryVariable(ArraySymbol): usage of this symbol. """ def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, - subject_to: None | list[tuple[int, str | list[str], float | list[float]] | - tuple[str | list[str], float | list[float]]] = None): + subject_to: None | list[tuple[str, float]] = None, + axes_subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = 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.AxisBound] cppbound_axes = _convert_python_bound_axes(subject_to) + cdef vector[BinaryNode.SumConstraint] cpp_sum_constraints = _convert_python_sum_constraints(subject_to, axes_subject_to) cdef const double[:] mem if lower_bound is not None: @@ -163,7 +171,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, cppbound_axes + cppshape, cpplower_bound, cppupper_bound, cpp_sum_constraints ) self.initialize_arraynode(model, self.ptr) @@ -206,20 +214,29 @@ cdef class BinaryVariable(ArraySymbol): # needs to be compatible with older versions try: - info = zf.getinfo(directory + "subject_to.json") + info = zf.getinfo(directory + "sum_constraints.json") except KeyError: subject_to = None + axes_subject_to = None else: with zf.open(info, "r") as f: + subject_to = [] + axes_subject_to = [] # 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 = [tuple(item) for item in json.load(f)] + for item in json.load(f): + if len(item) == 2: + # Inconvenient but `subject_to` expects scalars, not lists + subject_to.append((item[0][0], item[1][0])) + else: + axes_subject_to.append(tuple(item)) return BinaryVariable(model, shape=shape_info["shape"], lower_bound=lower_bound, upper_bound=upper_bound, - subject_to=subject_to + subject_to=subject_to, + axes_subject_to=axes_subject_to ) def _into_zipfile(self, zf, directory): @@ -243,30 +260,29 @@ 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: + sum_constraints = self.sum_constraints() + if len(sum_constraints) > 0: # Using json here converts the tuples to lists - zf.writestr(directory + "subject_to.json", encoder.encode(subject_to)) + zf.writestr(directory + "sum_constraints.json", encoder.encode(sum_constraints)) - 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)]) or ([operator(s)], [bound(s)]).""" - cdef vector[NumberNode.AxisBound] bound_axes = self.ptr.axis_wise_bounds() + def sum_constraints(self): + """Sum constraints of Binary symbol as a list of tuples where each tuple + is of the form: ([operator], [bound]) or (axis, [operator(s)], [bound(s)]).""" + cdef vector[NumberNode.SumConstraint] sum_constraints = self.ptr.sum_constraints() cdef optional[Py_ssize_t] axis output = [] - for i in range(bound_axes.size()): - bound_axis = &bound_axes[i] - axis = bound_axis.axis() - py_axis_ops = [_parse_cpp_operators(bound_axis.get_operator(j)) - for j in range(bound_axis.num_operators())] - py_axis_bounds = [bound_axis.get_bound(j) - for j in range(bound_axis.num_bounds())] + for i in range(sum_constraints.size()): + constraint = &sum_constraints[i] + axis = constraint.axis() + py_ops = [_parse_cpp_operators(constraint.get_operator(j)) for j in + range(constraint.num_operators())] + py_bounds = [constraint.get_bound(j) for j in range(constraint.num_bounds())] # axis may be nullopt if axis.has_value(): - output.append((axis.value(), py_axis_ops, py_axis_bounds)) + output.append((axis.value(), py_ops, py_bounds)) else: - output.append((py_axis_ops, py_axis_bounds)) + output.append((py_ops, py_bounds)) return output @@ -345,15 +361,15 @@ cdef class IntegerVariable(ArraySymbol): usage of this symbol. """ def __init__(self, _Graph model, shape=None, lower_bound=None, upper_bound=None, - subject_to: None | list[tuple[int, str | list[str], float | list[float]] | - tuple[str | list[str], float | list[float]]] = None): + subject_to: None | list[tuple[str, float]] = None, + axes_subject_to: None | list[tuple[int, str | list[str], float | list[float]]] = 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[IntegerNode.AxisBound] cppbound_axes = _convert_python_bound_axes(subject_to) + cdef vector[IntegerNode.SumConstraint] cpp_sum_constraints = _convert_python_sum_constraints(subject_to, axes_subject_to) cdef const double[:] mem if lower_bound is not None: @@ -383,7 +399,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, cppbound_axes + cppshape, cpplower_bound, cppupper_bound, cpp_sum_constraints ) self.initialize_arraynode(model, self.ptr) @@ -426,20 +442,29 @@ cdef class IntegerVariable(ArraySymbol): # needs to be compatible with older versions try: - info = zf.getinfo(directory + "subject_to.json") + info = zf.getinfo(directory + "sum_constraints.json") except KeyError: subject_to = None + axes_subject_to = None else: with zf.open(info, "r") as f: + subject_to = [] + axes_subject_to = [] # 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 = [tuple(item) for item in json.load(f)] + for item in json.load(f): + if len(item) == 2: + # Inconvenient but `subject_to` expects scalars, not lists + subject_to.append((item[0][0], item[1][0])) + else: + axes_subject_to.append(tuple(item)) return IntegerVariable(model, shape=shape_info["shape"], lower_bound=lower_bound, upper_bound=upper_bound, - subject_to=subject_to + subject_to=subject_to, + axes_subject_to=axes_subject_to ) def _into_zipfile(self, zf, directory): @@ -469,30 +494,29 @@ 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: + sum_constraints = self.sum_constraints() + if len(sum_constraints) > 0: # Using json here converts the tuples to lists - zf.writestr(directory + "subject_to.json", encoder.encode(subject_to)) + zf.writestr(directory + "sum_constraints.json", encoder.encode(sum_constraints)) - 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)]) or ([operator(s)], [bound(s)]).""" - cdef vector[NumberNode.AxisBound] bound_axes = self.ptr.axis_wise_bounds() + def sum_constraints(self): + """Sum constraints of Integer symbol as a list of tuples where each tuple + is of the form: ([operator], [bound]) or (axis, [operator(s)], [bound(s)]).""" + cdef vector[NumberNode.SumConstraint] sum_constraints = self.ptr.sum_constraints() cdef optional[Py_ssize_t] axis output = [] - for i in range(bound_axes.size()): - bound_axis = &bound_axes[i] - axis = bound_axis.axis() - py_axis_ops = [_parse_cpp_operators(bound_axis.get_operator(j)) - for j in range(bound_axis.num_operators())] - py_axis_bounds = [bound_axis.get_bound(j) - for j in range(bound_axis.num_bounds())] + for i in range(sum_constraints.size()): + constraint = &sum_constraints[i] + axis = constraint.axis() + py_ops = [_parse_cpp_operators(constraint.get_operator(j)) for j in + range(constraint.num_operators())] + py_bounds = [constraint.get_bound(j) for j in range(constraint.num_bounds())] # axis may be nullopt if axis.has_value(): - output.append((axis.value(), py_axis_ops, py_axis_bounds)) + output.append((axis.value(), py_ops, py_bounds)) else: - output.append((py_axis_ops, py_axis_bounds)) + output.append((py_ops, py_bounds)) return output diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index b047c6f9..b0a842cb 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -719,8 +719,8 @@ TEST_CASE("BinaryNode") { graph.emplace_node(6, lower_bounds, upper_bounds, sum_constraints); THEN("Sum constraint is correct") { - CHECK(bnode_ptr->sum_constraint().size() == 1); - SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_ptr->sum_constraints().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraints()[0]; CHECK(bnode_sum_constraint.axis() == std::nullopt); CHECK(bnode_sum_constraint.num_bounds() == 1); CHECK(bnode_sum_constraint.get_bound(0) == 3.0); @@ -732,12 +732,12 @@ TEST_CASE("BinaryNode") { auto state = graph.initialize_state(); graph.initialize_state(state); std::vector expected_init{0, 1, 1, 0, 0, 1}; - auto sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = bnode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 1); - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({3})); + CHECK(bnode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(bnode_ptr->sum_constraints_lhs(state).data()[0].size() == 1); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({3})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); } } @@ -754,8 +754,8 @@ TEST_CASE("BinaryNode") { lower_bounds, upper_bounds, sum_constraints); THEN("Sum constraint is correct") { - CHECK(bnode_ptr->sum_constraint().size() == 1); - SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_ptr->sum_constraints().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraints()[0]; CHECK(bnode_sum_constraint.axis() == 0); CHECK(bnode_sum_constraint.num_bounds() == 3); CHECK(bnode_sum_constraint.get_bound(0) == 1.0); @@ -785,12 +785,12 @@ TEST_CASE("BinaryNode") { // 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 sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = bnode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 3); - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 0, 3})); + CHECK(bnode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(bnode_ptr->sum_constraints_lhs(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 0, 3})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); } } @@ -806,8 +806,8 @@ TEST_CASE("BinaryNode") { lower_bounds, upper_bounds, sum_constraints); THEN("Sum constraint is correct") { - CHECK(bnode_ptr->sum_constraint().size() == 1); - SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_ptr->sum_constraints().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraints()[0]; CHECK(bnode_sum_constraint.axis() == 1); CHECK(bnode_sum_constraint.num_bounds() == 2); CHECK(bnode_sum_constraint.get_bound(0) == 1.0); @@ -834,12 +834,12 @@ TEST_CASE("BinaryNode") { // 0, 0 0, 1 std::vector expected_init{0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1}; - auto sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = bnode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 2); - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({0, 5})); + CHECK(bnode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(bnode_ptr->sum_constraints_lhs(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({0, 5})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); } } @@ -855,8 +855,8 @@ TEST_CASE("BinaryNode") { lower_bounds, upper_bounds, sum_constraints); THEN("Sum constraint is correct") { - CHECK(bnode_ptr->sum_constraint().size() == 1); - SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_ptr->sum_constraints().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraints()[0]; CHECK(bnode_sum_constraint.axis() == 2); CHECK(bnode_sum_constraint.num_bounds() == 2); CHECK(bnode_sum_constraint.get_bound(0) == 3.0); @@ -883,12 +883,12 @@ TEST_CASE("BinaryNode") { // 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 sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = bnode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 2); - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({3, 6})); + CHECK(bnode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(bnode_ptr->sum_constraints_lhs(state).data()[0].size() == 2); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({3, 6})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(expected_init)); } } @@ -951,8 +951,8 @@ TEST_CASE("BinaryNode") { std::nullopt, std::nullopt, sum_constraints); THEN("Sum constraint is correct") { - CHECK(bnode_ptr->sum_constraint().size() == 1); - SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_ptr->sum_constraints().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraints()[0]; CHECK(bnode_sum_constraint.axis() == std::nullopt); CHECK(bnode_sum_constraint.num_bounds() == 1); CHECK(bnode_sum_constraint.get_bound(0) == 5.0); @@ -973,12 +973,12 @@ TEST_CASE("BinaryNode") { bnode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = bnode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 1); - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({2.0})); + CHECK(bnode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(bnode_ptr->sum_constraints_lhs(state).data()[0].size() == 1); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({2.0})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -990,7 +990,7 @@ TEST_CASE("BinaryNode") { // state is now: [0, 0, 1, 0, 1, 0, 0, 0] THEN("Sum constraint sums and state updated correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({2.0})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({2.0})); CHECK(bnode_ptr->diff(state).size() == 2); // 2 updates per exchange CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -999,7 +999,7 @@ TEST_CASE("BinaryNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({2.0})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({2.0})); CHECK(bnode_ptr->diff(state).size() == 0); } } @@ -1016,8 +1016,8 @@ TEST_CASE("BinaryNode") { std::nullopt, std::nullopt, sum_constraints); THEN("Sum constraint is correct") { - CHECK(bnode_ptr->sum_constraint().size() == 1); - SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraint()[0]; + CHECK(bnode_ptr->sum_constraints().size() == 1); + SumConstraint bnode_sum_constraint = bnode_ptr->sum_constraints()[0]; CHECK(bnode_sum_constraint.axis() == 0); CHECK(bnode_sum_constraint.num_bounds() == 3); CHECK(bnode_sum_constraint.get_bound(0) == 1.0); @@ -1077,7 +1077,7 @@ TEST_CASE("BinaryNode") { bnode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto sum_constraint_sums = bnode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = bnode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { // **Python Code 1** @@ -1086,9 +1086,9 @@ TEST_CASE("BinaryNode") { // a = a.reshape(3, 2, 2) // a.sum(axis=(1, 2)) // >>> array([1, 2, 4]) - CHECK(bnode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(bnode_ptr->sum_constraint_sums(state).data()[0].size() == 3); - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK(bnode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(bnode_ptr->sum_constraints_lhs(state).data()[0].size() == 3); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 4})); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1107,7 +1107,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(3, a.shape)] = 1 // a.sum(axis=(1, 2)) // >>> array([1, 2, 4]) - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 2, 4})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(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)); } @@ -1116,7 +1116,7 @@ TEST_CASE("BinaryNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } @@ -1146,7 +1146,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(10, a.shape)] = 0 // a.sum(axis=(1, 2)) // >>> array([1, 1, 3]) - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 4); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1155,7 +1155,7 @@ TEST_CASE("BinaryNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } @@ -1187,7 +1187,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(1, 2)) // >>> array([1, 1, 3]) - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 4); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1196,7 +1196,7 @@ TEST_CASE("BinaryNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } @@ -1219,7 +1219,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(1, 2)) // >>> array([1, 2, 3]) - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 2, 3})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 3})); CHECK(bnode_ptr->diff(state).size() == 3); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1228,7 +1228,7 @@ TEST_CASE("BinaryNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 2, 4})); CHECK(bnode_ptr->diff(state).size() == 0); } @@ -1251,7 +1251,7 @@ TEST_CASE("BinaryNode") { // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(1, 2)) // >>> array([1, 1, 3]) - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], RangeEquals({1, 1, 3})); + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 2); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); } @@ -1266,7 +1266,7 @@ TEST_CASE("BinaryNode") { // state is now: [0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1] THEN("sum constraint sums updated correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 1, 4})); CHECK(bnode_ptr->diff(state).size() == 1); CHECK_THAT(bnode_ptr->view(state), RangeEquals(init_values)); @@ -1276,7 +1276,7 @@ TEST_CASE("BinaryNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1, 1, 3})); CHECK(bnode_ptr->diff(state).size() == 0); } @@ -1749,8 +1749,8 @@ TEST_CASE("IntegerNode") { -5, 8, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == std::nullopt); CHECK(inode_sum_constraint.num_bounds() == 1); CHECK(inode_sum_constraint.get_bound(0) == 40.0); @@ -1762,12 +1762,12 @@ TEST_CASE("IntegerNode") { auto state = graph.initialize_state(); graph.initialize_state(state); std::vector expected_init{8, 8, 8, 8, 8, 8, -3, -5}; - auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 1); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({40})); + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 1); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({40})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } @@ -1780,8 +1780,8 @@ TEST_CASE("IntegerNode") { -5, 8, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == 0); CHECK(inode_sum_constraint.num_bounds() == 2); CHECK(inode_sum_constraint.get_bound(0) == -21.0); @@ -1809,12 +1809,12 @@ TEST_CASE("IntegerNode") { // 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 sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 2); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({-21.0, 9.0})); + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({-21.0, 9.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } @@ -1828,8 +1828,8 @@ TEST_CASE("IntegerNode") { -5, 8, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == 1); CHECK(inode_sum_constraint.num_bounds() == 3); CHECK(inode_sum_constraint.get_bound(0) == 0.0); @@ -1862,12 +1862,12 @@ TEST_CASE("IntegerNode") { // [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 sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 3); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 3); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({0.0, -2.0, -20.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } @@ -1881,8 +1881,8 @@ TEST_CASE("IntegerNode") { -5, 8, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == 2); CHECK(inode_sum_constraint.num_bounds() == 2); CHECK(inode_sum_constraint.get_bound(0) == 23.0); @@ -1910,12 +1910,12 @@ TEST_CASE("IntegerNode") { // 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 sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 2); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({23.0, 14.0})); + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({23.0, 14.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(expected_init)); } } @@ -1972,8 +1972,8 @@ TEST_CASE("IntegerNode") { 8, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == std::nullopt); CHECK(inode_sum_constraint.num_bounds() == 1); CHECK(inode_sum_constraint.get_bound(0) == 5.0); @@ -1994,12 +1994,12 @@ TEST_CASE("IntegerNode") { inode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 1); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({5.0})); + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 1); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({5.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2011,7 +2011,7 @@ TEST_CASE("IntegerNode") { // state is now: [1.0, -1.0, 3.0, 5.0] THEN("Sum constraint sums and state updated correctly") { - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({8.0})); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({8.0})); CHECK(inode_ptr->diff(state).size() == 1); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2020,7 +2020,7 @@ TEST_CASE("IntegerNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(sum_constraint_sums[0], RangeEquals({5.0})); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({5.0})); CHECK(inode_ptr->diff(state).size() == 0); } } @@ -2036,8 +2036,8 @@ TEST_CASE("IntegerNode") { -5, 8, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == 1); CHECK(inode_sum_constraint.num_bounds() == 3); CHECK(inode_sum_constraint.get_bound(0) == 11.0); @@ -2097,7 +2097,7 @@ TEST_CASE("IntegerNode") { inode_ptr->initialize_state(state, init_values); graph.initialize_state(state); - auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { // **Python Code 2** @@ -2106,9 +2106,9 @@ TEST_CASE("IntegerNode") { // a = a.reshape(2, 3, 2) // a.sum(axis=(0, 2)) // >>> array([11, 2, 7]) - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 3); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, 2, 7})); + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 3); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({11, 2, 7})); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2131,7 +2131,7 @@ TEST_CASE("IntegerNode") { // a[np.unravel_index(1, a.shape)] = 5 // a.sum(axis=(0, 2)) // >>> array([11, 0, 9]) - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, 0, 9})); + CHECK_THAT(inode_ptr->sum_constraints_lhs(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)); } @@ -2140,7 +2140,7 @@ TEST_CASE("IntegerNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({11, 2, 7})); CHECK(inode_ptr->diff(state).size() == 0); } @@ -2161,7 +2161,7 @@ TEST_CASE("IntegerNode") { // a[np.unravel_index(10, a.shape)] = 8 // a.sum(axis=(0, 2)) // >>> array([11, -5, 15]) - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, -5, 15})); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({11, -5, 15})); CHECK(inode_ptr->diff(state).size() == 2); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2170,7 +2170,7 @@ TEST_CASE("IntegerNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({11, 2, 7})); CHECK(inode_ptr->diff(state).size() == 0); } @@ -2199,7 +2199,7 @@ TEST_CASE("IntegerNode") { // a[np.unravel_index(11, a.shape)] = 0 // a.sum(axis=(0, 2)) // >>> array([11, 1, 9]) - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({11, 1, 9})); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({11, 1, 9})); CHECK(inode_ptr->diff(state).size() == 4); CHECK_THAT(inode_ptr->view(state), RangeEquals(init_values)); } @@ -2208,7 +2208,7 @@ TEST_CASE("IntegerNode") { graph.revert(state); THEN("Sum constraint sums reverted correctly") { - CHECK_THAT(sum_constraint_sums[0], RangeEquals({11, 2, 7})); + CHECK_THAT(sum_constraints_lhs[0], RangeEquals({11, 2, 7})); CHECK(inode_ptr->diff(state).size() == 0); } } @@ -2223,8 +2223,8 @@ TEST_CASE("IntegerNode") { std::initializer_list{2, 3}, std::nullopt, std::nullopt, sum_constraints); THEN("Sum constraint is correct") { - CHECK(inode_ptr->sum_constraint().size() == 1); - SumConstraint inode_sum_constraint = inode_ptr->sum_constraint()[0]; + CHECK(inode_ptr->sum_constraints().size() == 1); + SumConstraint inode_sum_constraint = inode_ptr->sum_constraints()[0]; CHECK(inode_sum_constraint.axis() == 0); CHECK(inode_sum_constraint.num_bounds() == 1); CHECK(inode_sum_constraint.get_bound(0) == 1.0); @@ -2236,12 +2236,12 @@ TEST_CASE("IntegerNode") { auto state = graph.empty_state(); graph.initialize_state(state); - auto sum_constraint_sums = inode_ptr->sum_constraint_sums(state); + auto sum_constraints_lhs = inode_ptr->sum_constraints_lhs(state); THEN("Sum constraint sums and state are correct") { - CHECK(inode_ptr->sum_constraint_sums(state).size() == 1); - CHECK(inode_ptr->sum_constraint_sums(state).data()[0].size() == 2); - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({1.0, 1.0})); + CHECK(inode_ptr->sum_constraints_lhs(state).size() == 1); + CHECK(inode_ptr->sum_constraints_lhs(state).data()[0].size() == 2); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1.0, 1.0})); CHECK_THAT(inode_ptr->view(state), RangeEquals({1, 0, 0, 1, 0, 0})); } @@ -2250,7 +2250,7 @@ TEST_CASE("IntegerNode") { inode_ptr->exchange(state, 3, 4); THEN("Sum constraint sums and state updated correctly") { - CHECK_THAT(inode_ptr->sum_constraint_sums(state)[0], RangeEquals({1.0, 1.0})); + CHECK_THAT(inode_ptr->sum_constraints_lhs(state)[0], RangeEquals({1.0, 1.0})); CHECK(inode_ptr->diff(state).size() == 4); // 2 updates per exchange CHECK_THAT(inode_ptr->view(state), RangeEquals({0, 1, 0, 0, 1, 0})); } diff --git a/tests/test_symbols.py b/tests/test_symbols.py index abca76f0..cc80c4e9 100644 --- a/tests/test_symbols.py +++ b/tests/test_symbols.py @@ -748,59 +748,67 @@ def test_index_wise_bounds(self): with self.assertRaises(ValueError): 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([1, 2]))]) - self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) - x = model.binary((2, 3), subject_to=[(["=="], np.asarray([1]))]) - self.assertEqual(x.axis_wise_bounds(), [(["=="], [1])]) - x = model.binary((2, 3), subject_to=[("==", 1)]) - self.assertEqual(x.axis_wise_bounds(), [(["=="], [1])]) + def test_sum_constraints(self): + model = Model() - # 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)]) + # stores correct sum constraint + x = model.binary((2, 3), subject_to=[("==", 1)]) + self.assertEqual(x.sum_constraints(), [(["=="], [1])]) + x = model.binary((2, 3), subject_to=[("==", 1)]) + self.assertEqual(x.sum_constraints(), [(["=="], [1])]) + x = model.binary((2, 3), axes_subject_to=[(0, ["<=", "=="], [1, 2])]) + self.assertEqual(x.sum_constraints(), [(0, ["<=", "=="], [1, 2])]) + x = model.binary((2, 3), axes_subject_to=[(1, "<=", [1, 2, 1])]) + self.assertEqual(x.sum_constraints(), [(1, ["<="], [1, 2, 1])]) + x = model.binary((2, 3), axes_subject_to=[(0, ["<=", "=="], 1)]) + self.assertEqual(x.sum_constraints(), [(0, ["<=", "=="], [1])]) + x = model.binary((2, 3), axes_subject_to=[(0, "<=", 1)]) + self.assertEqual(x.sum_constraints(), [(0, ["<="], [1])]) + x = model.binary((2, 3), axes_subject_to=[(0, ["<=", "=="], np.asarray([1, 2]))]) + self.assertEqual(x.sum_constraints(), [(0, ["<=", "=="], [1, 2])]) + + # infeasible sum constraint with self.assertRaises(ValueError): model.binary((2, 3), upper_bound=[0, 1, 0, 0, 1, 0], subject_to=[(">=", 3)]) - - # incorrect number of axis-wise operators and or bounds with self.assertRaises(ValueError): - model.binary((2, 3), subject_to=[(0, "==", [0, 0, 0])]) + model.binary((2, 3), lower_bound=[0, 1, 0, 0, 1, 0], axes_subject_to=[(0, "==", 0)]) with self.assertRaises(ValueError): - model.binary((2, 3), subject_to=[(0, ["==", "<=", "=="], [0, 0])]) + model.binary((2, 3), lower_bound=[0, 1, 0, 0, 1, 0], axes_subject_to=[(0, "<=", 0)]) with self.assertRaises(ValueError): + model.binary((2, 3), upper_bound=[0, 1, 0, 0, 1, 0], axes_subject_to=[(0, ">=", 2)]) + + # incorrect number of operators and or bounds + with self.assertRaises(TypeError): model.binary((2, 3), subject_to=[("==", [0, 0, 0])]) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): model.binary((2, 3), subject_to=[(["==", "<=", "=="], [0])]) + with self.assertRaises(ValueError): + model.binary((2, 3), axes_subject_to=[(0, "==", [0, 0, 0])]) + with self.assertRaises(ValueError): + model.binary((2, 3), axes_subject_to=[(0, ["==", "<=", "=="], [0, 0])]) # check bad argument format with self.assertRaises(TypeError): - model.binary((2, 3), subject_to=[(1.1, "<=", [0, 0, 0])]) + model.binary((2, 3), axes_subject_to=[(1.1, "<=", [0, 0, 0])]) with self.assertRaises(TypeError): - model.binary((2, 3), subject_to=[(1, 4, [0, 0, 0])]) + model.binary((2, 3), axes_subject_to=[(1, 4, [0, 0, 0])]) with self.assertRaises(TypeError): - model.binary((2, 3), subject_to=[(1, ["!="], [0, 0, 0])]) + model.binary((2, 3), axes_subject_to=[(1, ["!="], [0, 0, 0])]) with self.assertRaises(TypeError): - model.binary((2, 3), subject_to=[(1, ["=="], [[0, 0, 0]])]) + model.binary((2, 3), axes_subject_to=[(1, ["=="], [[0, 0, 0]])]) with self.assertRaises(TypeError): - model.binary((2, 3), subject_to=[(1, [["<="]], [0, 0, 0])]) + model.binary((2, 3), axes_subject_to=[(1, [["<="]], [0, 0, 0])]) with self.assertRaises(TypeError): model.binary((2, 3), subject_to=[([["<="]], [0, 0, 0])]) + # invalid number of sum constraints + with self.assertRaises(ValueError): + model.binary((2, 3), subject_to=[("==", 1), ("<=", 0)]) + with self.assertRaises(ValueError): + model.binary((2, 3), subject_to=[("==", 1)], axes_subject_to=[(1, "<=", [1, 1, 1])]) + with self.assertRaises(ValueError): + model.binary((2, 3), axes_subject_to=[(0, "==", 1), (1, "<=", [1, 1, 1])]) + def test_no_shape(self): model = Model() x = model.binary() @@ -836,10 +844,10 @@ 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.binary(6, subject_to=[("<=", 2)]), model.binary((2, 3), subject_to=[("<=", 2)]), + model.binary((2, 3), axes_subject_to=[(1, "<=", [0, 1, 2])]), + model.binary((2, 3), axes_subject_to=[(0, ["<=", "=="], 1)]), ] model.lock() @@ -851,7 +859,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()) + self.assertEqual(old.sum_constraints(), new.sum_constraints()) def test_set_state(self): with self.subTest("array-like"): @@ -884,31 +892,32 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(1, 0) - with self.subTest("Simple axis-wise bounds test"): + with self.subTest("Simple sum constraint test"): model = Model() model.states.resize(1) - x = model.binary((2, 3), subject_to=[(0, "==", 1)]) + + x = model.binary((2, 2), subject_to=[("<=", 1)]) + x.set_state(0, [0, 1, 0, 0]) + # Do not satisfy sum constraint + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 1, 0, 1]) + + x = model.binary((2, 3), axes_subject_to=[(0, "==", 1)]) x.set_state(0, [0, 1, 0, 1, 0, 0]) - # Do not satisfy axis-wise bounds + # Do not satisfy sum constraint 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 = model.binary((2, 2), axes_subject_to=[(1, ["<=", ">="], [0, 2])]) x.set_state(0, [0, 1, 0, 1]) - # Do not satisfy axis-wise bounds + # Do not satisfy sum constraint 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]) - x = model.binary((2, 2), subject_to=[("<=", 1)]) - x.set_state(0, [0, 1, 0, 0]) - # Do not satisfy axis-wise bounds - with np.testing.assert_raises(ValueError): - x.set_state(0, [1, 1, 0, 1]) - with self.subTest("invalid state index"): model = Model() x = model.binary(5) @@ -1950,64 +1959,66 @@ def test_index_wise_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([1, 2]))]) - self.assertEqual(x.axis_wise_bounds(), [(0, ["<=", "=="], [1, 2])]) - x = model.integer((2, 3), subject_to=[(["=="], np.asarray([2]))]) - self.assertEqual(x.axis_wise_bounds(), [(["=="], [2])]) - x = model.integer((2, 3), subject_to=[("==", 2)]) - self.assertEqual(x.axis_wise_bounds(), [(["=="], [2])]) + def test_sum_constraints(self): + model = Model() - # 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)]) + # stores correct sum constraints + x = model.integer((2, 3), subject_to=[("==", 2)]) + self.assertEqual(x.sum_constraints(), [(["=="], [2])]) + x = model.integer((2, 3), subject_to=[("==", 2)]) + self.assertEqual(x.sum_constraints(), [(["=="], [2])]) + x = model.integer((2, 3), axes_subject_to=[(0, ["<=", "=="], [1, 2])]) + self.assertEqual(x.sum_constraints(), [(0, ["<=", "=="], [1, 2])]) + x = model.integer((2, 3), axes_subject_to=[(1, "<=", [1, 2, 1])]) + self.assertEqual(x.sum_constraints(), [(1, ["<="], [1, 2, 1])]) + x = model.integer((2, 3), axes_subject_to=[(0, ["<=", "=="], 1)]) + self.assertEqual(x.sum_constraints(), [(0, ["<=", "=="], [1])]) + x = model.integer((2, 3), axes_subject_to=[(0, "<=", 1)]) + self.assertEqual(x.sum_constraints(), [(0, ["<="], [1])]) + x = model.integer((2, 3), axes_subject_to=[(0, ["<=", "=="], np.asarray([1, 2]))]) + self.assertEqual(x.sum_constraints(), [(0, ["<=", "=="], [1, 2])]) + + # infeasible sum constraint with self.assertRaises(ValueError): model.integer((2, 2), upper_bound=2, subject_to=[(">=", 9)]) - - # incorrect number of axis-wise operators and or bounds with self.assertRaises(ValueError): - model.integer((2, 3), subject_to=[(0, "==", [10, 20, 30])]) + model.integer((2, 3), axes_subject_to=[(0, "==", -1)]) with self.assertRaises(ValueError): - model.integer((2, 3), subject_to=[(0, ["==", "<=", "=="], [10, 20])]) + model.integer((2, 3), lower_bound=0, axes_subject_to=[(0, "<=", -1)]) with self.assertRaises(ValueError): + model.integer((2, 3), upper_bound=2, axes_subject_to=[(0, ">=", 7)]) + + # incorrect number of operators and or bounds + with self.assertRaises(TypeError): model.integer((2, 3), subject_to=[("==", [10, 20, 30])]) - with self.assertRaises(ValueError): + with self.assertRaises(TypeError): model.integer((2, 3), subject_to=[(["==", "<=", "=="], 10)]) + with self.assertRaises(ValueError): + model.integer((2, 3), axes_subject_to=[(0, "==", [10, 20, 30])]) + with self.assertRaises(ValueError): + model.integer((2, 3), axes_subject_to=[(0, ["==", "<=", "=="], [10, 20])]) # bad argument format with self.assertRaises(TypeError): - model.integer((2, 3), subject_to=[(1.1, "<=", [0, 0, 0])]) + model.integer((2, 3), subject_to=[([["=="]], 0)]) with self.assertRaises(TypeError): - model.integer((2, 3), subject_to=[(1, 4, [0, 0, 0])]) + model.integer((2, 3), axes_subject_to=[(1.1, "<=", [0, 0, 0])]) with self.assertRaises(TypeError): - model.integer((2, 3), subject_to=[(1, ["!="], [0, 0, 0])]) + model.integer((2, 3), axes_subject_to=[(1, 4, [0, 0, 0])]) with self.assertRaises(TypeError): - model.integer((2, 3), subject_to=[(1, ["=="], [[0, 0, 0]])]) + model.integer((2, 3), axes_subject_to=[(1, ["!="], [0, 0, 0])]) with self.assertRaises(TypeError): - model.integer((2, 3), subject_to=[(1, [["=="]], [0, 0, 0])]) + model.integer((2, 3), axes_subject_to=[(1, ["=="], [[0, 0, 0]])]) with self.assertRaises(TypeError): - model.integer((2, 3), subject_to=[([["=="]], 0)]) + model.integer((2, 3), axes_subject_to=[(1, [["=="]], [0, 0, 0])]) - # invalid number of bound axes + # invalid number of sum constraints + with self.assertRaises(ValueError): + model.integer((2, 3), subject_to=[("==", 1), ("<=", 0)]) with self.assertRaises(ValueError): - model.integer((2, 3), subject_to=[(0, "==", 1), (1, "<=", [1, 1, 1])]) + model.integer((2, 3), subject_to=[("==", 1)], axes_subject_to=[(1, "<=", [1, 1, 1])]) with self.assertRaises(ValueError): - model.integer((2, 3), subject_to=[("==", 1), (1, "<=", [1, 1, 1])]) + model.integer((2, 3), axes_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 @@ -2029,10 +2040,10 @@ 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.integer(6, subject_to=[("<=", 2)]), - model.integer((2, 3), subject_to=[(["<="], 2)]), + model.integer((2, 3), subject_to=[("<=", 2)]), + model.integer((2, 3), axes_subject_to=[(1, "<=", [0, 1, 2])]), + model.integer((2, 3), axes_subject_to=[(0, ["<=", ">="], 2)]), ] model.lock() @@ -2044,7 +2055,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()) + self.assertEqual(old.sum_constraints(), new.sum_constraints()) def test_set_state(self): with self.subTest("Simple positive integer"): @@ -2080,33 +2091,34 @@ def test_set_state(self): with np.testing.assert_raises(ValueError): x.set_state(0, -2) - with self.subTest("Simple axis-wise bounds test"): + with self.subTest("Simple sum constraint test"): model = Model() model.states.resize(1) - x = model.integer((2, 3), subject_to=[(0, "==", 3)]) + + x = model.integer((2, 2), subject_to=[("==", 2)]) + x.set_state(0, [0, 2, 0, 0]) + # Do not satisfy sum constraint + with np.testing.assert_raises(ValueError): + x.set_state(0, [1, 1, 1, 1]) + with np.testing.assert_raises(ValueError): + x.set_state(0, [0, 0, 0, 1]) + + x = model.integer((2, 3), axes_subject_to=[(0, "==", 3)]) x.set_state(0, [0, 3, 0, 1, 1, 1]) - # Do not satisfy axis-wise bounds + # Do not satisfy sum constraint 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 = model.integer((2, 2), axes_subject_to=[(1, ["<=", ">="], [2, 6])]) x.set_state(0, [1, 6, 1, 10]) - # Do not satisfy axis-wise bounds + # Do not satisfy sum constraint 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]) - x = model.integer((2, 2), subject_to=[("==", 2)]) - x.set_state(0, [0, 2, 0, 0]) - # Do not satisfy axis-wise bounds - with np.testing.assert_raises(ValueError): - x.set_state(0, [1, 1, 1, 1]) - with np.testing.assert_raises(ValueError): - x.set_state(0, [0, 0, 0, 1]) - with self.subTest("array-like"): model = Model() model.states.resize(1) From b8da243a172d798792cbf728fa57aa075e0d5cb5 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Tue, 31 Mar 2026 12:04:04 -0700 Subject: [PATCH 28/31] Update Integer and Binary docs Reflect version bump. --- dwave/optimization/model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dwave/optimization/model.py b/dwave/optimization/model.py index f0011839..57f4da9b 100644 --- a/dwave/optimization/model.py +++ b/dwave/optimization/model.py @@ -273,8 +273,8 @@ def binary(self, shape: None | _ShapeLike = None, 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 sum constraints are + .. versionchanged:: 0.6.13 + Beginning in version 0.6.13, user-defined sum constraints are supported. """ from dwave.optimization.symbols import BinaryVariable # avoid circular import @@ -649,8 +649,8 @@ def integer( 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 sum constraints are + .. versionchanged:: 0.6.13 + Beginning in version 0.6.13, user-defined sum constraints are supported. """ from dwave.optimization.symbols import IntegerVariable # avoid circular import From 415e9a4750e17f20bc9e4d021e19c350f8b13e35 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Wed, 8 Apr 2026 14:37:34 -0700 Subject: [PATCH 29/31] Remove C++ tests post rebase 497 removed unset() and set() method. Removed them from sum constraint C++ tests. --- tests/cpp/nodes/test_numbers.cpp | 49 -------------------------------- 1 file changed, 49 deletions(-) diff --git a/tests/cpp/nodes/test_numbers.cpp b/tests/cpp/nodes/test_numbers.cpp index b0a842cb..c2505b9d 100644 --- a/tests/cpp/nodes/test_numbers.cpp +++ b/tests/cpp/nodes/test_numbers.cpp @@ -1234,55 +1234,6 @@ TEST_CASE("BinaryNode") { } } } - - 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("Sum constraint 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)) - // >>> array([1, 1, 3]) - CHECK_THAT(bnode_ptr->sum_constraints_lhs(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("sum constraint sums updated correctly") { - CHECK_THAT(bnode_ptr->sum_constraints_lhs(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("Sum constraint sums reverted correctly") { - CHECK_THAT(bnode_ptr->sum_constraints_lhs(state)[0], - RangeEquals({1, 1, 3})); - CHECK(bnode_ptr->diff(state).size() == 0); - } - } - } - } } } // *********************** Sum Constraint tests ************************* From 92035fb3b0be63cc9cfba42358be6abb3be7fd80 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 10 Apr 2026 17:07:33 -0700 Subject: [PATCH 30/31] Move `NumberNode::update_sum_constraints_lhs()` to cpp --- .../dwave-optimization/nodes/numbers.hpp | 7 +- dwave/optimization/src/nodes/numbers.cpp | 112 ++++++++---------- 2 files changed, 53 insertions(+), 66 deletions(-) diff --git a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp index 0e653481..42d9c852 100644 --- a/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp +++ b/dwave/optimization/include/dwave-optimization/nodes/numbers.hpp @@ -172,6 +172,8 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { /// given by `sum_constraints()`. const std::vector>& sum_constraints_lhs(const State& state) const; + std::vector>& sum_constraints_lhs(State& state) const; + protected: explicit NumberNode(std::span shape, std::vector lower_bound, std::vector upper_bound, @@ -183,11 +185,6 @@ class NumberNode : public ArrayOutputMixin, public DecisionNode { // Default value in a given index. virtual double default_value(ssize_t index) const = 0; - /// Update the relevant sum constraints running sums (`lhs`) given that the - /// value stored at `index` is changed by `value_change` in a given state. - void update_sum_constraints_lhs(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_; diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index 3bce4002..ad800543 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -128,17 +128,10 @@ std::vector> get_sum_constraints_lhs(const NumberNode* node, sum_constraints_lhs.reserve(num_sum_constraints); for (const NumberNode::SumConstraint& constraint : sum_constraints) { const std::optional axis = constraint.axis(); - // Handle the case where the sum constraint applies to the entire array. - if (!axis.has_value()) { - // Array is treated as a flat array with a single axis. - sum_constraints_lhs.emplace_back(1, 0.0); - continue; - } - assert(axis.has_value()); - assert(0 <= *axis && *axis < static_cast(node_shape.size())); - // Emplace an all zeros vector of size equal to the number of slice - // along the given constrained axis. - sum_constraints_lhs.emplace_back(node_shape[*axis], 0.0); + assert(!axis.has_value() || *axis < static_cast(node_shape.size())); + const ssize_t num_slices = axis.has_value() ? node_shape[*axis] : 1; + // Emplace an all zeros vector of size equal to the number of slices. + sum_constraints_lhs.emplace_back(num_slices, 0.0); } // Define a BufferIterator for `number_data` given the shape and strides of @@ -148,21 +141,12 @@ std::vector> get_sum_constraints_lhs(const NumberNode* node, // Increment the sum of the appropriate slice per sum constraint. for (ssize_t i = 0; i < num_sum_constraints; ++i) { const std::optional axis = sum_constraints[i].axis(); - // Handle the case where the sum constraint applies to the entire array. - if (!axis.has_value()) { - assert(sum_constraints_lhs[i].size() == 1); - sum_constraints_lhs[i].front() += *it; - continue; - } - assert(axis.has_value()); - assert(0 <= *axis && *axis < static_cast(it.location().size())); - const ssize_t slice = it.location()[*axis]; - assert(0 <= slice); - assert(slice < static_cast(sum_constraints_lhs[i].size())); + assert(!axis.has_value() || *axis < static_cast(it.location().size())); + const ssize_t slice = axis.has_value() ? it.location()[*axis] : 0; + assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); sum_constraints_lhs[i][slice] += *it; } } - return sum_constraints_lhs; } @@ -421,6 +405,41 @@ void NumberNode::revert(State& state) const noexcept { node_data->revert(); } +/// Update the relevant sum constraints running sums (`lhs`) given that the +/// value stored at `index` is changed by `value_change` in a given state. +void update_sum_constraints_lhs(const NumberNode& node, State& state, const ssize_t index, + const double value_change) { + const auto& sum_constraints = node.sum_constraints(); + assert(value_change != 0); // Should not call when no change occurs. + assert(sum_constraints.size() != 0); // Should only call where applicable. + + // Get multidimensional indices for `index` so we can identify the slices + // `index` lies on per sum constraint. + const std::vector multi_index = unravel_index(index, node.shape()); + assert(sum_constraints.size() <= multi_index.size()); + // Get the slice sums for all sum constraints. + auto& sum_constraints_lhs = node.sum_constraints_lhs(state); + assert(sum_constraints.size() == sum_constraints_lhs.size()); + + // For each sum constraint. + for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { + const std::optional axis = sum_constraints[i].axis(); + + // Handle the case where the constraint applies to the entire array. + if (!axis.has_value()) { + assert(sum_constraints_lhs[i].size() == 1); + sum_constraints_lhs[i].front() += value_change; + continue; + } + + assert(axis.has_value() && 0 <= *axis && *axis < static_cast(multi_index.size())); + // Get the slice along the constrained axis the `value_change` occurs in. + const ssize_t slice = multi_index[*axis]; + assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); + sum_constraints_lhs[i][slice] += value_change; // Offset slice sum. + } +} + void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { auto ptr = data_ptr(state); // We expect the exchange to obey the index-wise bounds. @@ -436,9 +455,9 @@ void NumberNode::exchange(State& state, ssize_t i, ssize_t j) const { if (!sum_constraints_all_equals_ && sum_constraints_.size() > 0) { const double difference = ptr->get(i) - ptr->get(j); // Index i changed from (what is now) ptr->get(j) to ptr->get(i) - update_sum_constraints_lhs(state, i, difference); + update_sum_constraints_lhs(*this, state, i, difference); // Index j changed from (what is now) ptr->get(i) to ptr->get(j) - update_sum_constraints_lhs(state, j, -difference); + update_sum_constraints_lhs(*this, state, j, -difference); } } } @@ -489,7 +508,7 @@ void NumberNode::clip_and_set_value(State& state, ssize_t index, double value) c if (ptr->set(index, value)) { // If change occurred and sum constraint exist, update running sums. if (sum_constraints_.size() > 0) { - update_sum_constraints_lhs(state, index, value - diff(state).back().old); + update_sum_constraints_lhs(*this, state, index, value - diff(state).back().old); } } } @@ -502,6 +521,10 @@ const std::vector>& NumberNode::sum_constraints_lhs(const St return data_ptr(state)->sum_constraints_lhs; } +std::vector>& NumberNode::sum_constraints_lhs(State& state) const { + return data_ptr(state)->sum_constraints_lhs; +} + template double get_extreme_index_wise_bound(const std::vector& bound) { assert(bound.size() > 0); @@ -640,39 +663,6 @@ NumberNode::NumberNode(std::span shape, std::vector lower check_sum_constraints(this); } -void NumberNode::update_sum_constraints_lhs(State& state, const ssize_t index, - const double value_change) const { - const auto& sum_constraints = this->sum_constraints(); - assert(value_change != 0); // Should not call when no change occurs. - assert(sum_constraints.size() != 0); // Should only call where applicable. - - // Get multidimensional indices for `index` so we can identify the slices - // `index` lies on per sum constraint. - const std::vector multi_index = unravel_index(index, this->shape()); - assert(sum_constraints.size() <= multi_index.size()); - // Get the slice sums for all sum constraints. - auto& sum_constraints_lhs = data_ptr(state)->sum_constraints_lhs; - assert(sum_constraints.size() == sum_constraints_lhs.size()); - - // For each sum constraint. - for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { - const std::optional axis = sum_constraints[i].axis(); - - // Handle the case where the constraint applies to the entire array. - if (!axis.has_value()) { - assert(sum_constraints_lhs[i].size() == 1); - sum_constraints_lhs[i].front() += value_change; - continue; - } - - assert(axis.has_value() && 0 <= *axis && *axis < static_cast(multi_index.size())); - // Get the slice along the constrained axis the `value_change` occurs in. - const ssize_t slice = multi_index[*axis]; - assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); - sum_constraints_lhs[i][slice] += value_change; // Offset slice sum. - } -} - // Integer Node *************************************************************** /// Check the user defined sum constraint for IntegerNode. @@ -781,7 +771,7 @@ void IntegerNode::set_value(State& state, ssize_t index, double value) const { if (ptr->set(index, value)) { // If change occurred and sum constraint exist, update running sums. if (sum_constraints_.size() > 0) { - update_sum_constraints_lhs(state, index, value - diff(state).back().old); + update_sum_constraints_lhs(*this, state, index, value - diff(state).back().old); } } } @@ -894,7 +884,7 @@ void BinaryNode::flip(State& state, ssize_t i) const { if (sum_constraints_.size() > 0) { // If value changed from 0 -> 1, update by 1. // If value changed from 1 -> 0, update by -1. - update_sum_constraints_lhs(state, i, (ptr->get(i) == 1) ? 1 : -1); + update_sum_constraints_lhs(*this, state, i, (ptr->get(i) == 1) ? 1 : -1); } } } From a2ece7fd82c61b8e7e525c20284c9d12c326d151 Mon Sep 17 00:00:00 2001 From: fastbodin Date: Fri, 10 Apr 2026 17:19:58 -0700 Subject: [PATCH 31/31] Simplify logic in `NumberNode` methods In particular, methods that handle sum constraints where `axis` is optional. --- dwave/optimization/src/nodes/numbers.cpp | 69 ++++++++++++------------ 1 file changed, 33 insertions(+), 36 deletions(-) diff --git a/dwave/optimization/src/nodes/numbers.cpp b/dwave/optimization/src/nodes/numbers.cpp index ad800543..bf085237 100644 --- a/dwave/optimization/src/nodes/numbers.cpp +++ b/dwave/optimization/src/nodes/numbers.cpp @@ -113,10 +113,10 @@ double NumberNode::max() const { return max_; } /// /// (*) If `axis == std::nullopt`, the constraint is applied to the entire /// array, which is treated as a flat array with a single slice. -std::vector> get_sum_constraints_lhs(const NumberNode* node, +std::vector> get_sum_constraints_lhs(const NumberNode& node, const std::vector& number_data) { - std::span node_shape = node->shape(); - const auto& sum_constraints = node->sum_constraints(); + std::span node_shape = node.shape(); + const auto& sum_constraints = node.sum_constraints(); const ssize_t num_sum_constraints = static_cast(sum_constraints.size()); assert(num_sum_constraints <= static_cast(node_shape.size())); assert(std::accumulate(node_shape.begin(), node_shape.end(), 1, std::multiplies()) == @@ -129,6 +129,8 @@ std::vector> get_sum_constraints_lhs(const NumberNode* node, for (const NumberNode::SumConstraint& constraint : sum_constraints) { const std::optional axis = constraint.axis(); assert(!axis.has_value() || *axis < static_cast(node_shape.size())); + /// If `axis == std::nullopt`, the array is treated as a flat array with a + /// single slice. Otherwise, the # of slices along axis is given by node shape. const ssize_t num_slices = axis.has_value() ? node_shape[*axis] : 1; // Emplace an all zeros vector of size equal to the number of slices. sum_constraints_lhs.emplace_back(num_slices, 0.0); @@ -136,12 +138,13 @@ std::vector> get_sum_constraints_lhs(const NumberNode* node, // 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()); + for (BufferIterator it(number_data.data(), node_shape, node.strides()); it != std::default_sentinel; ++it) { // Increment the sum of the appropriate slice per sum constraint. for (ssize_t i = 0; i < num_sum_constraints; ++i) { const std::optional axis = sum_constraints[i].axis(); assert(!axis.has_value() || *axis < static_cast(it.location().size())); + /// Determine the "slice" that the iterator lies on given the sum constraint. const ssize_t slice = axis.has_value() ? it.location()[*axis] : 0; assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); sum_constraints_lhs[i][slice] += *it; @@ -182,11 +185,11 @@ bool satisfies_sum_constraint(const std::vector& sum_ } void NumberNode::initialize_state(State& state, std::vector&& number_data) const { - if (number_data.size() != static_cast(this->size())) { + if (number_data.size() != static_cast(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) { + for (ssize_t index = 0, stop = size(); index < stop; ++index) { if (!is_valid(index, number_data[index])) { throw std::invalid_argument("Invalid data provided for node"); } @@ -197,7 +200,7 @@ void NumberNode::initialize_state(State& state, std::vector&& number_dat } else { // Given the assignment to NumberNode `number_data`, compute the sum // of the values within each slice per sum constraint. - auto sum_constraints_lhs = get_sum_constraints_lhs(this, number_data); + auto sum_constraints_lhs = get_sum_constraints_lhs(*this, number_data); if (!satisfies_sum_constraint(sum_constraints_, sum_constraints_lhs)) { throw std::invalid_argument("Initialized values do not satisfy sum constraint(s)."); @@ -270,21 +273,21 @@ double sum_constraint_delta(const double lhs, const NumberNode::SumConstraint::O /// A) Initially sets `values[i] = lower_bound(i)` for all i. /// B) Incremements the values within each slice until they satisfy /// the constraint (should this be possible). -void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, +void construct_state_given_exactly_one_sum_constraint(const NumberNode& node, std::vector& values) { - const std::span node_shape = node->shape(); + const std::span node_shape = node.shape(); const ssize_t ndim = node_shape.size(); // 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)); + for (ssize_t i = 0, stop = node.size(); i < stop; ++i) { + values.push_back(node.lower_bound(i)); } // 2) Determine the slice sums for the sum constraint. To improve performance, // compute sum during previous loop. - assert(node->sum_constraints().size() == 1); + assert(node.sum_constraints().size() == 1); const std::vector lhs = get_sum_constraints_lhs(node, values).front(); // Obtain the stateless sum constraint information. - const NumberNode::SumConstraint& constraint = node->sum_constraints().front(); + const NumberNode::SumConstraint& constraint = node.sum_constraints().front(); const std::optional axis = constraint.axis(); // Handle the case where the constraint applies to the entire array. @@ -295,9 +298,9 @@ void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, constraint.get_bound(0)); if (delta == 0) return; // Bound is satisfied for entire array. - for (ssize_t i = 0, stop = node->size(); i < stop; ++i) { + for (ssize_t i = 0, stop = node.size(); i < stop; ++i) { // Determine allowable amount we can increment the value in at `i`. - const double inc = std::min(delta, node->upper_bound(i) - values[i]); + const double inc = std::min(delta, node.upper_bound(i) - values[i]); if (inc > 0) { // Apply the increment to both `values` and `delta`. values[i] += inc; @@ -317,7 +320,7 @@ void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, // another. This is equivalent to adjusting the node's shape and strides // such that the data for the constrained axis is moved to position 0. const std::vector buff_shape = shift_axis_data(node_shape, *axis); - const std::vector buff_strides = shift_axis_data(node->strides(), *axis); + const std::vector buff_strides = shift_axis_data(node.strides(), *axis); // Define an iterator for `values` corresponding with the beginning of // slice 0 along the constrained axis. const BufferIterator slice_0_it(values.data(), ndim, buff_shape.data(), @@ -350,7 +353,7 @@ void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, 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); + const double inc = std::min(delta, node.upper_bound(index) - *slice_it); if (inc > 0) { // Apply the increment to both `it` and `delta`. *slice_it += inc; @@ -365,16 +368,16 @@ void construct_state_given_exactly_one_sum_constraint(const NumberNode* node, void NumberNode::initialize_state(State& state) const { std::vector values; - values.reserve(this->size()); + values.reserve(size()); if (sum_constraints_.size() == 0) { // No sum constraint to consider, initialize by default. - for (ssize_t i = 0, stop = this->size(); i < stop; ++i) { + for (ssize_t i = 0, stop = size(); i < stop; ++i) { values.push_back(default_value(i)); } initialize_state(state, std::move(values)); } else if (sum_constraints_.size() == 1) { - construct_state_given_exactly_one_sum_constraint(this, values); + construct_state_given_exactly_one_sum_constraint(*this, values); initialize_state(state, std::move(values)); } else { assert(false && "Multiple sum constraints not yet supported."); @@ -424,17 +427,11 @@ void update_sum_constraints_lhs(const NumberNode& node, State& state, const ssiz // For each sum constraint. for (ssize_t i = 0, stop = static_cast(sum_constraints.size()); i < stop; ++i) { const std::optional axis = sum_constraints[i].axis(); - - // Handle the case where the constraint applies to the entire array. - if (!axis.has_value()) { - assert(sum_constraints_lhs[i].size() == 1); - sum_constraints_lhs[i].front() += value_change; - continue; - } - - assert(axis.has_value() && 0 <= *axis && *axis < static_cast(multi_index.size())); - // Get the slice along the constrained axis the `value_change` occurs in. - const ssize_t slice = multi_index[*axis]; + /// Determine the "slice" that index lies on given the sum constraint. + /// If `axis == std::nullopt`, the array is treated as a flat array with a + /// single slice. Otherwise, the slice is defined by multi_index. + assert(!axis.has_value() || *axis < static_cast(multi_index.size())); + const ssize_t slice = axis.has_value() ? multi_index[*axis] : 0; assert(0 <= slice && slice < static_cast(sum_constraints_lhs[i].size())); sum_constraints_lhs[i][slice] += value_change; // Offset slice sum. } @@ -578,11 +575,11 @@ void check_index_wise_bounds(const NumberNode& node, const std::vector& } /// Check the user defined sum constraint(s). -void check_sum_constraints(const NumberNode* node) { - const std::vector& sum_constraints = node->sum_constraints(); +void check_sum_constraints(const NumberNode& node) { + const std::vector& sum_constraints = node.sum_constraints(); if (sum_constraints.size() == 0) return; // No sum constraints to check. - const std::span shape = node->shape(); + const std::span shape = node.shape(); // Used to assess if an axis is subject to multiple constraints. std::vector constrained_axis(shape.size(), false); // Used to assess if array is subject to multiple constraints. @@ -637,7 +634,7 @@ void check_sum_constraints(const NumberNode* node) { // There are fasters ways to check whether the sum constraints are feasible. // For now, fully attempt to construct a state and throw if impossible. std::vector values; - values.reserve(node->size()); + values.reserve(node.size()); construct_state_given_exactly_one_sum_constraint(node, values); } @@ -660,7 +657,7 @@ NumberNode::NumberNode(std::span shape, std::vector lower } check_index_wise_bounds(*this, lower_bounds_, upper_bounds_); - check_sum_constraints(this); + check_sum_constraints(*this); } // Integer Node ***************************************************************