From d6deb4fbc08cb44c1ac2f3e56eff312df1ea1c95 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 9 Jun 2026 16:59:36 -0400 Subject: [PATCH 1/7] feat: add pick command for reduce Add a pick command to algorithm::reduce, which selects an arbitrary subset of bins by index from an axis which is not ordered, like the category axis. Unlike slice, the picked bins do not have to be adjacent, and they may be given in any order, which reorders the bins in the new axis. Counts in bins which are not picked are added to the overflow bin, if it is present, consistent with how slice treats unordered axes; otherwise they are discarded. Axes opt into picking with a new special constructor which accepts the original axis and a vector of bin indices to keep. The new trait axis::traits::is_pickable detects this constructor, mirroring is_reducible. axis::category implements it; ordered axes throw invalid_argument, since picking a non-adjacent subset from them would require changing the axis type, which reduce does not support yet. Fixes #275 Assisted-by: ClaudeCode:claude-fable-5 --- doc/changelog.qbk | 5 + doc/concepts/Axis.qbk | 8 ++ doc/guide.qbk | 4 +- examples/guide_histogram_reduction.cpp | 18 +++ include/boost/histogram/algorithm/reduce.hpp | 124 +++++++++++++++--- include/boost/histogram/axis/category.hpp | 7 + include/boost/histogram/axis/traits.hpp | 24 ++++ .../boost/histogram/detail/reduce_command.hpp | 10 +- test/algorithm_reduce_test.cpp | 93 +++++++++++++ test/axis_traits_test.cpp | 18 +++ 10 files changed, 287 insertions(+), 24 deletions(-) diff --git a/doc/changelog.qbk b/doc/changelog.qbk index f4e6fae27..e8169d3f8 100644 --- a/doc/changelog.qbk +++ b/doc/changelog.qbk @@ -7,6 +7,11 @@ [section:changelog Changelog] +[heading Next release] + +* Added `pick` command for `algorithm::reduce`, which selects an arbitrary subset of bins from a category axis; unlike `slice`, the bins do not have to be adjacent + * New trait `axis::traits::is_pickable` detects whether an axis supports picking; user-defined axes can opt-in by adding a special constructor, see the Axis concept + [heading Boost 1.89] * Update CMake minimum version and Python detection in CMake diff --git a/doc/concepts/Axis.qbk b/doc/concepts/Axis.qbk index dc13388cd..a6668f123 100644 --- a/doc/concepts/Axis.qbk +++ b/doc/concepts/Axis.qbk @@ -53,6 +53,7 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l * `a` and `b` are values of type `A` * `i` and `j` are indices of type [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] * `n` is a value of type `unsigned` +* `v` is a value of type `std::vector` * `M` is a metadata type that is [@https://en.cppreference.com/w/cpp/named_req/DefaultConstructible DefaultConstructible], [@https://en.cppreference.com/w/cpp/named_req/CopyConstructible CopyConstructible] and [@https://en.cppreference.com/w/cpp/named_req/CopyAssignable CopyAssignable]. It it supports moves, it must be *nothrow* [@https://en.cppreference.com/w/cpp/named_req/MoveAssignable MoveAssignable]. * `ar` is a value of an archive with Boost.Serialization semantics @@ -72,6 +73,13 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l Special constructor used by the reduce algorithm. `a` is the original axis instance, `i` and `j` are the index range to keep in the reduced axis. If `n` is larger than 1, `n` adjacent bins are merged into one larger cell. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to reduce this axis. ] ] +[ + [`A(a, v)`] + [] + [ + Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `v` is a `std::vector` of [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. + ] +] [ [`a.options()`] [`unsigned`] diff --git a/doc/guide.qbk b/doc/guide.qbk index fdc230842..31f3d353e 100644 --- a/doc/guide.qbk +++ b/doc/guide.qbk @@ -293,9 +293,9 @@ The library provides the [funcref boost::histogram::algorithm::project] function [section Reduction] -A projection removes an axis completely. A less drastic way to obtain a smaller histogram is the [funcref boost::histogram::algorithm::reduce reduce] function, which allows one to /slice/, /shrink/ or /rebin/ individual axes. +A projection removes an axis completely. A less drastic way to obtain a smaller histogram is the [funcref boost::histogram::algorithm::reduce reduce] function, which allows one to /slice/, /shrink/, /pick/ or /rebin/ individual axes. -Shrinking means that the value range of an axis is reduced and the number of bins along that axis. Slicing does the same, but is based on axis indices while shrinking is based on the axis values. To /rebin/ means that adjacent bins are merged into larger bins, the histogram is made coarser. For N adjacent bins, a new bin is formed which covers the common interval of the merged bins and has their added content. These two operations can be combined and applied to several axes at once. Doing it in one step is much more efficient than doing it in several steps. +Shrinking means that the value range of an axis is reduced and the number of bins along that axis. Slicing does the same, but is based on axis indices while shrinking is based on the axis values. To /rebin/ means that adjacent bins are merged into larger bins, the histogram is made coarser. For N adjacent bins, a new bin is formed which covers the common interval of the merged bins and has their added content. These two operations can be combined and applied to several axes at once. Doing it in one step is much more efficient than doing it in several steps. Picking selects an arbitrary subset of bins by index from an axis which is not ordered, like the category axis. Unlike a slice, the picked bins do not have to be adjacent. The [funcref boost::histogram::algorithm::reduce reduce] function does not change the total count if all modified axes in the histogram have underflow and overflow bins. Counts in removed bins are added to the corresponding under- and overflow bins. As in case of the [funcref boost::histogram::algorithm::project project] function, such a histogram is guaranteed to be identical to one obtained from filling the original data. diff --git a/examples/guide_histogram_reduction.cpp b/examples/guide_histogram_reduction.cpp index 677625305..5d55ff3a4 100644 --- a/examples/guide_histogram_reduction.cpp +++ b/examples/guide_histogram_reduction.cpp @@ -8,10 +8,12 @@ #include #include +#include int main() { using namespace boost::histogram; // import reduce commands into local namespace to save typing + using algorithm::pick; using algorithm::rebin; using algorithm::shrink; using algorithm::slice; @@ -43,6 +45,22 @@ int main() { assert(h3.axis(0) == h.axis(0)); // unchanged assert(h3.axis(1) == axis::regular<>(2, 0.0, 2.0)); + + // pick selects an arbitrary subset of bins from an axis which is not ordered, like + // the category axis; unlike a slice, the picked bins do not have to be adjacent + auto h4 = make_histogram(axis::category({"red", "green", "blue"})); + + h4("red"); + h4("green"); + h4("blue"); + + // pick the bins for "blue" and "red", in that order + auto h5 = algorithm::reduce(h4, pick({2, 0})); + + assert(h5.axis(0) == axis::category({"blue", "red"})); + assert(h5.at(0) == 1 && h5.at(1) == 1); + // the count for "green" was moved to the overflow bin of the category axis + assert(h5.at(2) == 1); } //] diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index c342d6aad..c28c9feb5 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -7,6 +7,7 @@ #ifndef BOOST_HISTOGRAM_ALGORITHM_REDUCE_HPP #define BOOST_HISTOGRAM_ALGORITHM_REDUCE_HPP +#include #include #include #include @@ -21,6 +22,8 @@ #include #include #include +#include +#include namespace boost { namespace histogram { @@ -316,14 +319,67 @@ inline reduce_command slice_and_rebin(axis::index_type begin, axis::index_type e return slice_and_rebin(reduce_command::unset, begin, end, merge, mode); } -/** Shrink, crop, slice, and/or rebin axes of a histogram. +/** Pick command to be used in `reduce`. + + Command is applied to axis with given index. + + Picking selects an arbitrary subset of bins by index. The new axis consists of the + picked bins in the order in which the indices are given, which may differ from their + order in the original axis. In contrast to `slice`, the picked bins do not have to be + adjacent. Each index must be valid and may only appear once. + + Picking only works on axes that are not ordered, like the category axis, since + removing an arbitrary subset of bins from an ordered axis would create gaps in the + axis range. The counts in bins that were not picked are added to the overflow bin, + if it is present. If it is not present, the counts are discarded. + + @param iaxis which axis to operate on. + @param indices indices of the bins to keep, must be unique. +*/ +inline reduce_command pick(unsigned iaxis, std::vector indices) { + if (indices.empty()) + BOOST_THROW_EXCEPTION(std::invalid_argument("at least one index required")); + for (auto it = indices.begin(); it != indices.end(); ++it) + if (std::find(indices.begin(), it, *it) != it) + BOOST_THROW_EXCEPTION(std::invalid_argument("indices must be unique")); + reduce_command r; + r.iaxis = iaxis; + r.range = reduce_command::range_t::indices_list; + r.indices = std::move(indices); + r.merge = 1; + r.crop = false; + return r; +} + +/** Pick command to be used in `reduce`. + + Command is applied to corresponding axis in order of reduce arguments. + + Picking selects an arbitrary subset of bins by index. The new axis consists of the + picked bins in the order in which the indices are given, which may differ from their + order in the original axis. In contrast to `slice`, the picked bins do not have to be + adjacent. Each index must be valid and may only appear once. + + Picking only works on axes that are not ordered, like the category axis, since + removing an arbitrary subset of bins from an ordered axis would create gaps in the + axis range. The counts in bins that were not picked are added to the overflow bin, + if it is present. If it is not present, the counts are discarded. + + @param indices indices of the bins to keep, must be unique. +*/ +inline reduce_command pick(std::vector indices) { + return pick(reduce_command::unset, std::move(indices)); +} + +/** Shrink, crop, slice, pick, and/or rebin axes of a histogram. Returns a new reduced histogram and leaves the original histogram untouched. The commands `rebin` and `shrink` or `slice` for the same axis are automatically combined, this is not an error. Passing a `shrink` and a `slice` command for the same axis or two `rebin` commands triggers an `invalid_argument` - exception. Trying to reducing a non-reducible axis triggers an `invalid_argument` + exception. The `pick` command cannot be combined with any other command for the + same axis. Trying to reducing a non-reducible axis triggers an `invalid_argument` exception. Histograms with non-reducible axes can still be reduced along the other axes that are reducible. @@ -331,8 +387,8 @@ inline reduce_command slice_and_rebin(axis::index_type begin, axis::index_type e @param hist original histogram. @param options iterable sequence of reduce commands: `shrink`, `slice`, `rebin`, - `shrink_and_rebin`, or `slice_and_rebin`. The element type of the iterable should be - `reduce_command`. + `pick`, `shrink_and_rebin`, or `slice_and_rebin`. The element type of the iterable + should be `reduce_command`. */ template > Histogram reduce(const Histogram& hist, const Iterable& options) { @@ -351,6 +407,21 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { if (o.merge > 0) { // option is set? o.use_underflow_bin = AO::test(axis::option::underflow); o.use_overflow_bin = AO::test(axis::option::overflow); + if (o.range == reduce_command::range_t::indices_list) + return detail::static_if_c::value>( + [&o](const auto& a_in) { + using A = std::decay_t; + for (const auto idx : o.indices) + if (idx < 0 || idx >= a_in.size()) + BOOST_THROW_EXCEPTION(std::invalid_argument("index out of range")); + return A(a_in, o.indices); + }, + [iaxis](const auto& a_in) { + return BOOST_THROW_EXCEPTION(std::invalid_argument( + "axis " + std::to_string(iaxis) + " is not pickable")), + a_in; + }, + a_in); return detail::static_if_c::value>( [&o](const auto& a_in) { if (o.range == reduce_command::range_t::none) { @@ -412,21 +483,33 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { bool skip = false; for (auto j : x.indices()) { - *i = (j - o->begin.index); - if (o->is_ordered && *i <= -1) { - *i = -1; - if (!o->use_underflow_bin) skip = true; - } else { - if (*i >= 0) - *i /= static_cast(o->merge); - else - *i = o->end.index; - const auto reduced_axis_end = - (o->end.index - o->begin.index) / static_cast(o->merge); - if (*i >= reduced_axis_end) { - *i = reduced_axis_end; + if (o->range == reduce_command::range_t::indices_list) { + // pick: map index to position in the list of picked indices; + // indices that are not picked are mapped to the overflow bin + const auto it = std::find(o->indices.begin(), o->indices.end(), j); + if (it != o->indices.end()) + *i = static_cast(std::distance(o->indices.begin(), it)); + else { + *i = static_cast(o->indices.size()); if (!o->use_overflow_bin) skip = true; } + } else { + *i = (j - o->begin.index); + if (o->is_ordered && *i <= -1) { + *i = -1; + if (!o->use_underflow_bin) skip = true; + } else { + if (*i >= 0) + *i /= static_cast(o->merge); + else + *i = o->end.index; + const auto reduced_axis_end = + (o->end.index - o->begin.index) / static_cast(o->merge); + if (*i >= reduced_axis_end) { + *i = reduced_axis_end; + if (!o->use_overflow_bin) skip = true; + } + } } ++i; @@ -439,21 +522,22 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { return result; } -/** Shrink, slice, and/or rebin axes of a histogram. +/** Shrink, crop, slice, pick, and/or rebin axes of a histogram. Returns a new reduced histogram and leaves the original histogram untouched. The commands `rebin` and `shrink` or `slice` for the same axis are automatically combined, this is not an error. Passing a `shrink` and a `slice` command for the same axis or two `rebin` commands triggers an invalid_argument - exception. It is safe to reduce histograms with some axis that are not reducible along + exception. The `pick` command cannot be combined with any other command for the + same axis. It is safe to reduce histograms with some axis that are not reducible along the other axes. Trying to reducing a non-reducible axis triggers an invalid_argument exception. An overload allows one to pass an iterable of reduce_command. @param hist original histogram. - @param opt first reduce command; one of `shrink`, `slice`, `rebin`, + @param opt first reduce command; one of `shrink`, `slice`, `rebin`, `pick`, `shrink_and_rebin`, or `slice_or_rebin`. @param opts more reduce commands. */ diff --git a/include/boost/histogram/axis/category.hpp b/include/boost/histogram/axis/category.hpp index 9802c95d4..7772ee6e4 100644 --- a/include/boost/histogram/axis/category.hpp +++ b/include/boost/histogram/axis/category.hpp @@ -145,6 +145,13 @@ class category : public iterator_mixin& indices) + : metadata_base(metadata_type(src.metadata())), vec_(src.get_allocator()) { + vec_.reserve(indices.size()); + for (const index_type idx : indices) vec_.emplace_back(src.vec_[idx]); + } + /// Return index for value argument. index_type index(const value_type& x) const noexcept { const auto beg = vec_.begin(); diff --git a/include/boost/histogram/axis/traits.hpp b/include/boost/histogram/axis/traits.hpp index 26dce06b1..10a2e6ef8 100644 --- a/include/boost/histogram/axis/traits.hpp +++ b/include/boost/histogram/axis/traits.hpp @@ -23,6 +23,7 @@ #include #include #include +#include namespace boost { namespace histogram { @@ -178,6 +179,29 @@ using is_reducible = std::is_constructible +#ifndef BOOST_HISTOGRAM_DOXYGEN_INVOKED +using is_pickable = + std::is_constructible&>; +#else +struct is_pickable; +#endif + /** Get axis options for axis type. Doxygen does not render this well. This is a meta-function (template alias), it accepts diff --git a/include/boost/histogram/detail/reduce_command.hpp b/include/boost/histogram/detail/reduce_command.hpp index 3fb67b502..59a2169eb 100644 --- a/include/boost/histogram/detail/reduce_command.hpp +++ b/include/boost/histogram/detail/reduce_command.hpp @@ -13,6 +13,7 @@ #include #include #include +#include namespace boost { namespace histogram { @@ -25,12 +26,14 @@ struct reduce_command { none, indices, values, + indices_list, } range = range_t::none; union { axis::index_type index; double value; } begin{0}, end{0}; - unsigned merge = 0; // default value indicates unset option + std::vector indices; // only used by range_t::indices_list + unsigned merge = 0; // default value indicates unset option bool crop = false; // for internal use by the reduce algorithm bool is_ordered = true; @@ -54,9 +57,12 @@ inline void normalize_reduce_commands(span out, o_out = o_in; } else { // Some command was already set for this axis, try to fuse commands. + // A pick command cannot be fused with any other command. if (!((o_in.range == reduce_command::range_t::none) ^ (o_out.range == reduce_command::range_t::none)) || - (o_out.merge > 1 && o_in.merge > 1)) + (o_out.merge > 1 && o_in.merge > 1) || + o_in.range == reduce_command::range_t::indices_list || + o_out.range == reduce_command::range_t::indices_list) BOOST_THROW_EXCEPTION(std::invalid_argument( "multiple conflicting reduce commands for axis " + std::to_string(o_in.iaxis == reduce_command::unset ? iaxis : o_in.iaxis))); diff --git a/test/algorithm_reduce_test.cpp b/test/algorithm_reduce_test.cpp index 49608318a..3cbc0ae0c 100644 --- a/test/algorithm_reduce_test.cpp +++ b/test/algorithm_reduce_test.cpp @@ -69,6 +69,17 @@ void run_tests() { // not allowed: reducing unreducible axis BOOST_TEST_THROWS((void)reduce(make(Tag(), unreducible{}), slice(0, 1)), std::invalid_argument); + // not allowed: pick with empty index list + BOOST_TEST_THROWS((void)pick(0, {}), std::invalid_argument); + // not allowed: pick with duplicated indices + BOOST_TEST_THROWS((void)pick(0, {1, 1}), std::invalid_argument); + // not allowed: pick on axis which is not pickable + BOOST_TEST_THROWS((void)reduce(h, pick(0, {1})), std::invalid_argument); + // not allowed: pick combined with any other command for the same axis + BOOST_TEST_THROWS((void)reduce(h, pick(0, {1}), rebin(0, 2)), std::invalid_argument); + BOOST_TEST_THROWS((void)reduce(h, slice(0, 0, 2), pick(0, {1})), + std::invalid_argument); + BOOST_TEST_THROWS((void)reduce(h, pick(0, {1}), pick(0, {2})), std::invalid_argument); } // shrink and crop behavior when value on edge and not on edge is inclusive: @@ -319,6 +330,12 @@ void run_tests() { BOOST_TEST_EQ(hr.axis(2), (CI{{2, 3}})); BOOST_TEST_EQ(hr.axis(3), u); BOOST_TEST_THROWS((void)algorithm::reduce(h, rebin(2, 2)), std::invalid_argument); + + auto hr2 = algorithm::reduce(h, shrink(0, 2, 4), pick(2, {2, 0})); + BOOST_TEST_EQ(hr2.axis(0), (R{2, 2, 4})); + BOOST_TEST_EQ(hr2.axis(1), (V{{1., 2., 3.}})); + BOOST_TEST_EQ(hr2.axis(2), (CI{{3, 1}})); + BOOST_TEST_EQ(hr2.axis(3), u); } // reduce on integer axis, rebin must fail @@ -417,6 +434,82 @@ void run_tests() { BOOST_TEST_EQ(hr[0], 1); BOOST_TEST_EQ(hr[1], 3); } + + // pick on category axis: bins which are not picked are added to overflow bin + { + auto h = make(Tag(), CI{{1, 2, 3}}); + std::fill(h.begin(), h.end(), 1); + // original: [1: 1, 2: 1, 3: 1, overflow: 1] + + // not allowed: pick index out of range + BOOST_TEST_THROWS((void)reduce(h, pick({3})), std::invalid_argument); + BOOST_TEST_THROWS((void)reduce(h, pick({-1})), std::invalid_argument); + + auto hr = reduce(h, pick({0, 2})); + // reduced: [1: 1, 3: 1, overflow: 2] + BOOST_TEST_EQ(hr.axis(), (CI{{1, 3}})); + BOOST_TEST_EQ(hr[0], 1); + BOOST_TEST_EQ(hr[1], 1); + BOOST_TEST_EQ(hr[2], 2); + BOOST_TEST_EQ(sum(hr), 4); + + // picked bins are returned in the order in which the indices are given + auto hr2 = reduce(h, pick({2, 0})); + BOOST_TEST_EQ(hr2.axis(), (CI{{3, 1}})); + BOOST_TEST_EQ(hr2[0], 1); + BOOST_TEST_EQ(hr2[1], 1); + BOOST_TEST_EQ(hr2[2], 2); + + // test overload that accepts iterable + std::vector opts{{pick(0, {0, 2})}}; + auto hr3 = reduce(h, opts); + BOOST_TEST_EQ(hr3, hr); + } + + // pick on category axis without overflow bin: bins which are not picked are discarded + { + using CIN = axis::category; + auto h = make(Tag(), CIN{{1, 2, 3}}); + std::fill(h.begin(), h.end(), 1); + // original: [1: 1, 2: 1, 3: 1] + auto hr = reduce(h, pick({1})); + // reduced: [2: 1] + BOOST_TEST_EQ(hr.axis(), (CIN{{2}})); + BOOST_TEST_EQ(hr.size(), 1); + BOOST_TEST_EQ(hr[0], 1); + BOOST_TEST_EQ(sum(hr), 1); + } + + // pick on category axis of 2d histogram: other axes are not affected + { + auto h = make_s(Tag(), std::vector(), CI{{1, 2, 3}}, ID(0, 2)); + + /* + matrix layout: + x (category) -> + y 1 2 3 of + | 0 1 2 3 6 + v 1 4 0 5 0 + */ + h.at(0, 0) = 1; + h.at(1, 0) = 2; + h.at(2, 0) = 3; + h.at(3, 0) = 6; // overflow of category axis + h.at(0, 1) = 4; + h.at(2, 1) = 5; + + auto hr = reduce(h, pick(0, {2, 0})); + BOOST_TEST_EQ(hr.rank(), 2); + BOOST_TEST_EQ(sum(hr), 21); + BOOST_TEST_EQ(hr.axis(0), (CI{{3, 1}})); + BOOST_TEST_EQ(hr.axis(1), ID(0, 2)); + BOOST_TEST_EQ(hr.at(0, 0), 3); + BOOST_TEST_EQ(hr.at(1, 0), 1); + BOOST_TEST_EQ(hr.at(2, 0), 8); // not picked + original overflow + BOOST_TEST_EQ(hr.at(0, 1), 5); + BOOST_TEST_EQ(hr.at(1, 1), 4); + BOOST_TEST_EQ(hr.at(2, 1), 0); + } } int main() { diff --git a/test/axis_traits_test.cpp b/test/axis_traits_test.cpp index 43d344d7c..30f798e95 100644 --- a/test/axis_traits_test.cpp +++ b/test/axis_traits_test.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include "axis.hpp" #include "ostream.hpp" #include "throw_exception.hpp" @@ -69,6 +70,23 @@ int main() { BOOST_TEST_TRAIT_TRUE((traits::is_reducible>)); } + // is_pickable + { + struct not_pickable {}; + struct pickable { + pickable(const pickable&, const std::vector&); + }; + + BOOST_TEST_TRAIT_TRUE((traits::is_pickable)); + BOOST_TEST_TRAIT_FALSE((traits::is_pickable)); + + BOOST_TEST_TRAIT_FALSE((traits::is_pickable>)); + BOOST_TEST_TRAIT_FALSE((traits::is_pickable>)); + BOOST_TEST_TRAIT_FALSE((traits::is_pickable>)); + BOOST_TEST_TRAIT_FALSE((traits::is_pickable>)); + BOOST_TEST_TRAIT_TRUE((traits::is_pickable>)); + } + // get_options, options() { using A = integer<>; From 89b85b5179508e96e67ba6fd13b88ffc31d1c655 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 14 Jun 2026 21:21:54 -0400 Subject: [PATCH 2/7] fix: resolve MSVC build failures for pick reduce Windows CI failed two ways: - C1128 (too many sections) when compiling algorithm_reduce_test.cpp; add /bigobj for the test under CMake, matching the b2 build which already sets it globally and the existing fill/operators tests. - C4702 (unreachable code, treated as error) for the non-pickable static_if branch in reduce(). Unlike is_reducible (true for all standard axes), is_pickable is false for most axes, so that branch is codegen'd and MSVC flags the value after the noreturn throw. Guard the function with a 4702 pragma, mirroring detail/fill.hpp. Assisted-by: ClaudeCode:claude-opus-4.8 --- include/boost/histogram/algorithm/reduce.hpp | 10 ++++++++++ test/CMakeLists.txt | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index c28c9feb5..40c568f83 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -8,6 +8,7 @@ #define BOOST_HISTOGRAM_ALGORITHM_REDUCE_HPP #include +#include #include #include #include @@ -390,6 +391,11 @@ inline reduce_command pick(std::vector indices) { `pick`, `shrink_and_rebin`, or `slice_and_rebin`. The element type of the iterable should be `reduce_command`. */ +#if BOOST_WORKAROUND(BOOST_MSVC, >= 0) +#pragma warning(push) +#pragma warning(disable : 4702) // unreachable code in the non-pickable static_if branch +#endif + template > Histogram reduce(const Histogram& hist, const Iterable& options) { using axis::index_type; @@ -522,6 +528,10 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { return result; } +#if BOOST_WORKAROUND(BOOST_MSVC, >= 0) +#pragma warning(pop) +#endif + /** Shrink, crop, slice, pick, and/or rebin axes of a histogram. Returns a new reduced histogram and leaves the original histogram untouched. diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 719a81c71..747ce00ba 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -56,7 +56,8 @@ boost_test(TYPE run SOURCES accumulators_weighted_mean_test.cpp) boost_test(TYPE run SOURCES accumulators_weighted_sum_test.cpp) boost_test(TYPE run SOURCES accumulators_collector_test.cpp) boost_test(TYPE run SOURCES algorithm_project_test.cpp) -boost_test(TYPE run SOURCES algorithm_reduce_test.cpp) +boost_test(TYPE run SOURCES algorithm_reduce_test.cpp + COMPILE_OPTIONS $<$:/bigobj>) boost_test(TYPE run SOURCES algorithm_sum_test.cpp) boost_test(TYPE run SOURCES algorithm_empty_test.cpp) boost_test(TYPE run SOURCES axis_boolean_test.cpp) From ee4ccbde712aa1e5b77f56413729d7bac0aefbbf Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 23 Jul 2026 01:42:08 -0400 Subject: [PATCH 3/7] refactor: disambiguate pick constructor with axis::pick_tag The is_pickable trait matched any axis whose generic (iterable, metadata) constructor accepts a vector of indices, e.g. variable>, which broke compilation of reduce() with any command on such axes. The pick constructor now takes an axis::pick_tag so only axes that opt in match the trait. Also mirror the MSVC /bigobj flag in test/Jamfile, simplify the pick branch of the fill loop, hoist index validation out of the static_if, and copy metadata via metadata_base(src) like the shrink constructors. Assisted-by: ClaudeCode:claude-fable-5 --- doc/concepts/Axis.qbk | 7 +++--- include/boost/histogram/algorithm/reduce.hpp | 24 ++++++++------------ include/boost/histogram/axis/category.hpp | 4 ++-- include/boost/histogram/axis/traits.hpp | 12 +++++----- include/boost/histogram/fwd.hpp | 3 +++ test/Jamfile | 3 ++- test/axis_traits_test.cpp | 2 +- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/doc/concepts/Axis.qbk b/doc/concepts/Axis.qbk index a6668f123..ec4996b1c 100644 --- a/doc/concepts/Axis.qbk +++ b/doc/concepts/Axis.qbk @@ -53,7 +53,8 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l * `a` and `b` are values of type `A` * `i` and `j` are indices of type [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] * `n` is a value of type `unsigned` -* `v` is a value of type `std::vector` +* `t` is a value of type [headerref boost/histogram/fwd.hpp `boost::histogram::axis::pick_tag`] +* `idxs` is a value of type `std::vector` * `M` is a metadata type that is [@https://en.cppreference.com/w/cpp/named_req/DefaultConstructible DefaultConstructible], [@https://en.cppreference.com/w/cpp/named_req/CopyConstructible CopyConstructible] and [@https://en.cppreference.com/w/cpp/named_req/CopyAssignable CopyAssignable]. It it supports moves, it must be *nothrow* [@https://en.cppreference.com/w/cpp/named_req/MoveAssignable MoveAssignable]. * `ar` is a value of an archive with Boost.Serialization semantics @@ -74,10 +75,10 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l ] ] [ - [`A(a, v)`] + [`A(a, t, idxs)`] [] [ - Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `v` is a `std::vector` of [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. + Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `t` is a tag that marks this constructor unambiguously, `idxs` is a `std::vector` of [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. ] ] [ diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index 40c568f83..13405b2ca 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -413,14 +413,13 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { if (o.merge > 0) { // option is set? o.use_underflow_bin = AO::test(axis::option::underflow); o.use_overflow_bin = AO::test(axis::option::overflow); - if (o.range == reduce_command::range_t::indices_list) + if (o.range == reduce_command::range_t::indices_list) { + for (const auto idx : o.indices) + if (idx < 0 || idx >= a_in.size()) + BOOST_THROW_EXCEPTION(std::invalid_argument("index out of range")); return detail::static_if_c::value>( [&o](const auto& a_in) { - using A = std::decay_t; - for (const auto idx : o.indices) - if (idx < 0 || idx >= a_in.size()) - BOOST_THROW_EXCEPTION(std::invalid_argument("index out of range")); - return A(a_in, o.indices); + return std::decay_t(a_in, axis::pick_tag{}, o.indices); }, [iaxis](const auto& a_in) { return BOOST_THROW_EXCEPTION(std::invalid_argument( @@ -428,6 +427,7 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { a_in; }, a_in); + } return detail::static_if_c::value>( [&o](const auto& a_in) { if (o.range == reduce_command::range_t::none) { @@ -490,15 +490,11 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { for (auto j : x.indices()) { if (o->range == reduce_command::range_t::indices_list) { - // pick: map index to position in the list of picked indices; - // indices that are not picked are mapped to the overflow bin + // pick: map index to its position in the list of picked indices; + // unpicked indices land one past the end, which is the overflow bin const auto it = std::find(o->indices.begin(), o->indices.end(), j); - if (it != o->indices.end()) - *i = static_cast(std::distance(o->indices.begin(), it)); - else { - *i = static_cast(o->indices.size()); - if (!o->use_overflow_bin) skip = true; - } + *i = static_cast(std::distance(o->indices.begin(), it)); + if (it == o->indices.end() && !o->use_overflow_bin) skip = true; } else { *i = (j - o->begin.index); if (o->is_ordered && *i <= -1) { diff --git a/include/boost/histogram/axis/category.hpp b/include/boost/histogram/axis/category.hpp index 7772ee6e4..8bc293abc 100644 --- a/include/boost/histogram/axis/category.hpp +++ b/include/boost/histogram/axis/category.hpp @@ -146,8 +146,8 @@ class category : public iterator_mixin& indices) - : metadata_base(metadata_type(src.metadata())), vec_(src.get_allocator()) { + category(const category& src, pick_tag, const std::vector& indices) + : metadata_base(src), vec_(src.get_allocator()) { vec_.reserve(indices.size()); for (const index_type idx : indices) vec_.emplace_back(src.vec_[idx]); } diff --git a/include/boost/histogram/axis/traits.hpp b/include/boost/histogram/axis/traits.hpp index 10a2e6ef8..d23e3ff0f 100644 --- a/include/boost/histogram/axis/traits.hpp +++ b/include/boost/histogram/axis/traits.hpp @@ -187,17 +187,17 @@ struct is_reducible; boost::histogram::algorithm::reduce(), using the pick command. An axis can be made pickable by adding a special constructor, which accepts the - original axis and a vector of bin indices to keep, see Axis concept for details. This - usually only makes sense for axes which are not ordered, like the category axis, since - picking an arbitrary subset of bins from an ordered axis would create gaps in the axis - range. + original axis, an axis::pick_tag, and a vector of bin indices to keep, see Axis + concept for details. This usually only makes sense for axes which are not ordered, + like the category axis, since picking an arbitrary subset of bins from an ordered + axis would create gaps in the axis range. @tparam Axis axis type. */ template #ifndef BOOST_HISTOGRAM_DOXYGEN_INVOKED -using is_pickable = - std::is_constructible&>; +using is_pickable = std::is_constructible&>; #else struct is_pickable; #endif diff --git a/include/boost/histogram/fwd.hpp b/include/boost/histogram/fwd.hpp index 9e54f2565..439fe23ef 100644 --- a/include/boost/histogram/fwd.hpp +++ b/include/boost/histogram/fwd.hpp @@ -41,6 +41,9 @@ struct null_type { /// Another alias for an empty metadata type using empty_type = null_type; +/// Tag type for the axis constructor that picks bins (see algorithm::reduce) +struct pick_tag {}; + // some forward declarations must be hidden from doxygen to fix the reference docu :( #ifndef BOOST_HISTOGRAM_DOXYGEN_INVOKED diff --git a/test/Jamfile b/test/Jamfile index c156e1240..57b8d0d5f 100644 --- a/test/Jamfile +++ b/test/Jamfile @@ -63,7 +63,8 @@ alias cxx14 : [ run accumulators_weighted_sum_test.cpp ] [ run accumulators_collector_test.cpp ] [ run algorithm_project_test.cpp ] - [ run algorithm_reduce_test.cpp ] + [ run algorithm_reduce_test.cpp : : : + msvc:/bigobj ] [ run algorithm_sum_test.cpp ] [ run algorithm_empty_test.cpp ] [ run axis_boolean_test.cpp ] diff --git a/test/axis_traits_test.cpp b/test/axis_traits_test.cpp index 30f798e95..2b68c6dee 100644 --- a/test/axis_traits_test.cpp +++ b/test/axis_traits_test.cpp @@ -74,7 +74,7 @@ int main() { { struct not_pickable {}; struct pickable { - pickable(const pickable&, const std::vector&); + pickable(const pickable&, pick_tag, const std::vector&); }; BOOST_TEST_TRAIT_TRUE((traits::is_pickable)); From fe4ef4d4a2e47a9a5a1e7923d9cfe6d56de6f4ee Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 23 Jul 2026 09:25:24 -0400 Subject: [PATCH 4/7] perf: use a lookup table for pick in reduce Replace the per-cell linear search over the picked indices with a lookup table built once per axis. Picking 1000 of 2000 categories in a 2000x500 histogram drops from ~200 ms to ~15 ms, on par with slice. The table also routes the underflow bin of a hypothetical pickable axis to the overflow bin instead of reading out of bounds; the Axis concept now documents this. Also move the MSVC pragma block above the doc comment and clarify in the guide that pick cannot be combined with other commands. Assisted-by: ClaudeCode:claude-fable-5 --- doc/concepts/Axis.qbk | 2 +- doc/guide.qbk | 2 +- include/boost/histogram/algorithm/reduce.hpp | 30 +++++++++++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/doc/concepts/Axis.qbk b/doc/concepts/Axis.qbk index ec4996b1c..2a5f7ff33 100644 --- a/doc/concepts/Axis.qbk +++ b/doc/concepts/Axis.qbk @@ -78,7 +78,7 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l [`A(a, t, idxs)`] [] [ - Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `t` is a tag that marks this constructor unambiguously, `idxs` is a `std::vector` of [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. + Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `t` is a tag that marks this constructor unambiguously, `idxs` is a `std::vector` of [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If the axis has an underflow bin, its counts are added to the overflow bin of the new axis, like the counts of bins which were not picked. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. ] ] [ diff --git a/doc/guide.qbk b/doc/guide.qbk index 31f3d353e..79b4ac110 100644 --- a/doc/guide.qbk +++ b/doc/guide.qbk @@ -295,7 +295,7 @@ The library provides the [funcref boost::histogram::algorithm::project] function A projection removes an axis completely. A less drastic way to obtain a smaller histogram is the [funcref boost::histogram::algorithm::reduce reduce] function, which allows one to /slice/, /shrink/, /pick/ or /rebin/ individual axes. -Shrinking means that the value range of an axis is reduced and the number of bins along that axis. Slicing does the same, but is based on axis indices while shrinking is based on the axis values. To /rebin/ means that adjacent bins are merged into larger bins, the histogram is made coarser. For N adjacent bins, a new bin is formed which covers the common interval of the merged bins and has their added content. These two operations can be combined and applied to several axes at once. Doing it in one step is much more efficient than doing it in several steps. Picking selects an arbitrary subset of bins by index from an axis which is not ordered, like the category axis. Unlike a slice, the picked bins do not have to be adjacent. +Shrinking means that the value range of an axis is reduced and the number of bins along that axis. Slicing does the same, but is based on axis indices while shrinking is based on the axis values. To /rebin/ means that adjacent bins are merged into larger bins, the histogram is made coarser. For N adjacent bins, a new bin is formed which covers the common interval of the merged bins and has their added content. Picking selects an arbitrary subset of bins by index from an axis which is not ordered, like the category axis. Unlike a slice, the picked bins do not have to be adjacent. Shrinking or slicing can be combined with rebinning and applied to several axes at once; picking cannot be combined with the other operations on the same axis. Doing it in one step is much more efficient than doing it in several steps. The [funcref boost::histogram::algorithm::reduce reduce] function does not change the total count if all modified axes in the histogram have underflow and overflow bins. Counts in removed bins are added to the corresponding under- and overflow bins. As in case of the [funcref boost::histogram::algorithm::project project] function, such a histogram is guaranteed to be identical to one obtained from filling the original data. diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index 13405b2ca..33a6b1368 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -372,6 +372,11 @@ inline reduce_command pick(std::vector indices) { return pick(reduce_command::unset, std::move(indices)); } +#if BOOST_WORKAROUND(BOOST_MSVC, >= 0) +#pragma warning(push) +#pragma warning(disable : 4702) // unreachable code in the non-pickable static_if branch +#endif + /** Shrink, crop, slice, pick, and/or rebin axes of a histogram. Returns a new reduced histogram and leaves the original histogram untouched. @@ -391,11 +396,6 @@ inline reduce_command pick(std::vector indices) { `pick`, `shrink_and_rebin`, or `slice_and_rebin`. The element type of the iterable should be `reduce_command`. */ -#if BOOST_WORKAROUND(BOOST_MSVC, >= 0) -#pragma warning(push) -#pragma warning(disable : 4702) // unreachable code in the non-pickable static_if branch -#endif - template > Histogram reduce(const Histogram& hist, const Iterable& options) { using axis::index_type; @@ -419,7 +419,16 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { BOOST_THROW_EXCEPTION(std::invalid_argument("index out of range")); return detail::static_if_c::value>( [&o](const auto& a_in) { - return std::decay_t(a_in, axis::pick_tag{}, o.indices); + auto a_out = + std::decay_t(a_in, axis::pick_tag{}, o.indices); + // replace pick list with a lookup table from old to new index; + // unpicked bins and the old overflow bin map to o.end.index, + // the overflow bin of the new axis + o.end.index = static_cast(o.indices.size()); + std::vector lut(a_in.size() + 1, o.end.index); + for (index_type k = 0; k < o.end.index; ++k) lut[o.indices[k]] = k; + o.indices = std::move(lut); + return a_out; }, [iaxis](const auto& a_in) { return BOOST_THROW_EXCEPTION(std::invalid_argument( @@ -490,11 +499,10 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { for (auto j : x.indices()) { if (o->range == reduce_command::range_t::indices_list) { - // pick: map index to its position in the list of picked indices; - // unpicked indices land one past the end, which is the overflow bin - const auto it = std::find(o->indices.begin(), o->indices.end(), j); - *i = static_cast(std::distance(o->indices.begin(), it)); - if (it == o->indices.end() && !o->use_overflow_bin) skip = true; + // pick: o->indices is a lookup table from old to new index; unpicked bins + // and flow bins map to o->end.index, the overflow bin of the new axis + *i = j < 0 ? o->end.index : o->indices[j]; + if (*i == o->end.index && !o->use_overflow_bin) skip = true; } else { *i = (j - o->begin.index); if (o->is_ordered && *i <= -1) { From 89a17cc662b4dbc13df34a473328324fefbe138e Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 23 Jul 2026 09:25:41 -0400 Subject: [PATCH 5/7] feat: add crop mode to the pick command pick accepts an optional slice_mode like slice. In crop mode the counts of unpicked bins and of the original overflow bin are discarded instead of being moved to the overflow bin. Assisted-by: ClaudeCode:claude-fable-5 --- doc/changelog.qbk | 2 +- include/boost/histogram/algorithm/reduce.hpp | 20 ++++++++++++++------ test/algorithm_reduce_test.cpp | 8 ++++++++ 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/doc/changelog.qbk b/doc/changelog.qbk index e8169d3f8..37142183a 100644 --- a/doc/changelog.qbk +++ b/doc/changelog.qbk @@ -9,7 +9,7 @@ [heading Next release] -* Added `pick` command for `algorithm::reduce`, which selects an arbitrary subset of bins from a category axis; unlike `slice`, the bins do not have to be adjacent +* Added `pick` command for `algorithm::reduce`, which selects an arbitrary subset of bins from a category axis; unlike `slice`, the bins do not have to be adjacent; supports the same crop mode as `slice` * New trait `axis::traits::is_pickable` detects whether an axis supports picking; user-defined axes can opt-in by adding a special constructor, see the Axis concept [heading Boost 1.89] diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index 33a6b1368..1b77b5bf4 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -332,12 +332,15 @@ inline reduce_command slice_and_rebin(axis::index_type begin, axis::index_type e Picking only works on axes that are not ordered, like the category axis, since removing an arbitrary subset of bins from an ordered axis would create gaps in the axis range. The counts in bins that were not picked are added to the overflow bin, - if it is present. If it is not present, the counts are discarded. + if it is present. If it is not present, the counts are discarded. In crop mode, the + counts in unpicked bins and in the original overflow bin are always discarded. @param iaxis which axis to operate on. @param indices indices of the bins to keep, must be unique. + @param mode whether to behave like `shrink` or `crop` regarding removed bins. */ -inline reduce_command pick(unsigned iaxis, std::vector indices) { +inline reduce_command pick(unsigned iaxis, std::vector indices, + slice_mode mode = slice_mode::shrink) { if (indices.empty()) BOOST_THROW_EXCEPTION(std::invalid_argument("at least one index required")); for (auto it = indices.begin(); it != indices.end(); ++it) @@ -348,7 +351,7 @@ inline reduce_command pick(unsigned iaxis, std::vector indices r.range = reduce_command::range_t::indices_list; r.indices = std::move(indices); r.merge = 1; - r.crop = false; + r.crop = mode == slice_mode::crop; return r; } @@ -364,12 +367,15 @@ inline reduce_command pick(unsigned iaxis, std::vector indices Picking only works on axes that are not ordered, like the category axis, since removing an arbitrary subset of bins from an ordered axis would create gaps in the axis range. The counts in bins that were not picked are added to the overflow bin, - if it is present. If it is not present, the counts are discarded. + if it is present. If it is not present, the counts are discarded. In crop mode, the + counts in unpicked bins and in the original overflow bin are always discarded. @param indices indices of the bins to keep, must be unique. + @param mode whether to behave like `shrink` or `crop` regarding removed bins. */ -inline reduce_command pick(std::vector indices) { - return pick(reduce_command::unset, std::move(indices)); +inline reduce_command pick(std::vector indices, + slice_mode mode = slice_mode::shrink) { + return pick(reduce_command::unset, std::move(indices), mode); } #if BOOST_WORKAROUND(BOOST_MSVC, >= 0) @@ -417,6 +423,8 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { for (const auto idx : o.indices) if (idx < 0 || idx >= a_in.size()) BOOST_THROW_EXCEPTION(std::invalid_argument("index out of range")); + // crop discards counts of unpicked bins instead of moving them to overflow + if (o.crop) o.use_overflow_bin = false; return detail::static_if_c::value>( [&o](const auto& a_in) { auto a_out = diff --git a/test/algorithm_reduce_test.cpp b/test/algorithm_reduce_test.cpp index 3cbc0ae0c..7caa4ac75 100644 --- a/test/algorithm_reduce_test.cpp +++ b/test/algorithm_reduce_test.cpp @@ -464,6 +464,14 @@ void run_tests() { std::vector opts{{pick(0, {0, 2})}}; auto hr3 = reduce(h, opts); BOOST_TEST_EQ(hr3, hr); + + // crop mode discards counts in unpicked bins and in the original overflow bin + auto hr4 = reduce(h, pick({0, 2}, slice_mode::crop)); + BOOST_TEST_EQ(hr4.axis(), (CI{{1, 3}})); + BOOST_TEST_EQ(hr4[0], 1); + BOOST_TEST_EQ(hr4[1], 1); + BOOST_TEST_EQ(hr4[2], 0); + BOOST_TEST_EQ(sum(hr4), 2); } // pick on category axis without overflow bin: bins which are not picked are discarded From 21fa98bf6a607139729076013c3ab439f7d2998b Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Thu, 23 Jul 2026 09:36:31 -0400 Subject: [PATCH 6/7] refactor: use a pointer range instead of std::vector in the pick constructor The pick constructor was the only user-facing signature in the library that requires std::vector. A pointer range matches the iterator-pair idiom of the other axis constructors and the primitive arguments of the reduce constructor, and does not freeze a container type into the Axis concept. Assisted-by: ClaudeCode:claude-fable-5 --- doc/changelog.qbk | 5 ----- doc/concepts/Axis.qbk | 6 +++--- include/boost/histogram/algorithm/reduce.hpp | 5 +++-- include/boost/histogram/axis/category.hpp | 6 +++--- include/boost/histogram/axis/traits.hpp | 14 +++++++------- test/axis_traits_test.cpp | 3 +-- 6 files changed, 17 insertions(+), 22 deletions(-) diff --git a/doc/changelog.qbk b/doc/changelog.qbk index 37142183a..f4e6fae27 100644 --- a/doc/changelog.qbk +++ b/doc/changelog.qbk @@ -7,11 +7,6 @@ [section:changelog Changelog] -[heading Next release] - -* Added `pick` command for `algorithm::reduce`, which selects an arbitrary subset of bins from a category axis; unlike `slice`, the bins do not have to be adjacent; supports the same crop mode as `slice` - * New trait `axis::traits::is_pickable` detects whether an axis supports picking; user-defined axes can opt-in by adding a special constructor, see the Axis concept - [heading Boost 1.89] * Update CMake minimum version and Python detection in CMake diff --git a/doc/concepts/Axis.qbk b/doc/concepts/Axis.qbk index 2a5f7ff33..67b7a1bba 100644 --- a/doc/concepts/Axis.qbk +++ b/doc/concepts/Axis.qbk @@ -54,7 +54,7 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l * `i` and `j` are indices of type [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] * `n` is a value of type `unsigned` * `t` is a value of type [headerref boost/histogram/fwd.hpp `boost::histogram::axis::pick_tag`] -* `idxs` is a value of type `std::vector` +* `b` and `e` are values of type `const boost::histogram::axis::index_type*` which form the index range `[b, e)` * `M` is a metadata type that is [@https://en.cppreference.com/w/cpp/named_req/DefaultConstructible DefaultConstructible], [@https://en.cppreference.com/w/cpp/named_req/CopyConstructible CopyConstructible] and [@https://en.cppreference.com/w/cpp/named_req/CopyAssignable CopyAssignable]. It it supports moves, it must be *nothrow* [@https://en.cppreference.com/w/cpp/named_req/MoveAssignable MoveAssignable]. * `ar` is a value of an archive with Boost.Serialization semantics @@ -75,10 +75,10 @@ An [*Axis] maps input values to indices. It holds state specific to that axis, l ] ] [ - [`A(a, t, idxs)`] + [`A(a, t, b, e)`] [] [ - Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `t` is a tag that marks this constructor unambiguously, `idxs` is a `std::vector` of [headerref boost/histogram/fwd.hpp `boost::histogram::axis::index_type`] with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If the axis has an underflow bin, its counts are added to the overflow bin of the new axis, like the counts of bins which were not picked. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. + Special constructor used by the reduce algorithm to handle the pick command. `a` is the original axis instance, `t` is a tag that marks this constructor unambiguously, `b` and `e` delimit a range with the indices of the bins to keep, in the order in which they should appear in the new axis. Should only be implemented for axes which are not ordered, like the category axis. If the axis has an underflow bin, its counts are added to the overflow bin of the new axis, like the counts of bins which were not picked. If this constructor is not implemented, [funcref boost::histogram::algorithm::reduce] throws an exception on an attempt to pick bins from this axis. ] ] [ diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index 1b77b5bf4..314404541 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -427,8 +427,9 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { if (o.crop) o.use_overflow_bin = false; return detail::static_if_c::value>( [&o](const auto& a_in) { - auto a_out = - std::decay_t(a_in, axis::pick_tag{}, o.indices); + auto a_out = std::decay_t( + a_in, axis::pick_tag{}, o.indices.data(), + o.indices.data() + o.indices.size()); // replace pick list with a lookup table from old to new index; // unpicked bins and the old overflow bin map to o.end.index, // the overflow bin of the new axis diff --git a/include/boost/histogram/axis/category.hpp b/include/boost/histogram/axis/category.hpp index 8bc293abc..1f00b0ce8 100644 --- a/include/boost/histogram/axis/category.hpp +++ b/include/boost/histogram/axis/category.hpp @@ -146,10 +146,10 @@ class category : public iterator_mixin& indices) + category(const category& src, pick_tag, const index_type* begin, const index_type* end) : metadata_base(src), vec_(src.get_allocator()) { - vec_.reserve(indices.size()); - for (const index_type idx : indices) vec_.emplace_back(src.vec_[idx]); + vec_.reserve(static_cast(end - begin)); + for (; begin != end; ++begin) vec_.emplace_back(src.vec_[*begin]); } /// Return index for value argument. diff --git a/include/boost/histogram/axis/traits.hpp b/include/boost/histogram/axis/traits.hpp index d23e3ff0f..60cba9b64 100644 --- a/include/boost/histogram/axis/traits.hpp +++ b/include/boost/histogram/axis/traits.hpp @@ -23,7 +23,6 @@ #include #include #include -#include namespace boost { namespace histogram { @@ -187,17 +186,18 @@ struct is_reducible; boost::histogram::algorithm::reduce(), using the pick command. An axis can be made pickable by adding a special constructor, which accepts the - original axis, an axis::pick_tag, and a vector of bin indices to keep, see Axis - concept for details. This usually only makes sense for axes which are not ordered, - like the category axis, since picking an arbitrary subset of bins from an ordered - axis would create gaps in the axis range. + original axis, an axis::pick_tag, and a pointer range of bin indices to keep, see + Axis concept for details. This usually only makes sense for axes which are not + ordered, like the category axis, since picking an arbitrary subset of bins from an + ordered axis would create gaps in the axis range. @tparam Axis axis type. */ template #ifndef BOOST_HISTOGRAM_DOXYGEN_INVOKED -using is_pickable = std::is_constructible&>; +using is_pickable = + std::is_constructible; #else struct is_pickable; #endif diff --git a/test/axis_traits_test.cpp b/test/axis_traits_test.cpp index 2b68c6dee..1cb7f8400 100644 --- a/test/axis_traits_test.cpp +++ b/test/axis_traits_test.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include "axis.hpp" #include "ostream.hpp" #include "throw_exception.hpp" @@ -74,7 +73,7 @@ int main() { { struct not_pickable {}; struct pickable { - pickable(const pickable&, pick_tag, const std::vector&); + pickable(const pickable&, pick_tag, const index_type*, const index_type*); }; BOOST_TEST_TRAIT_TRUE((traits::is_pickable)); From a139cebd318196e3df1870593794bef5e0959b43 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 3 Aug 2026 15:20:01 -0400 Subject: [PATCH 7/7] refactor: clean up pick implementation after review - Store the pick lookup table in a dedicated reduce_command member instead of overwriting the pick list mid-algorithm. - Cross-reference the primary pick() overload docs instead of duplicating them. - Drop the per-test /bigobj flag from the Jamfile; the project requirements already apply it to all MSVC tests. Assisted-by: ClaudeCode:claude-fable-5 --- include/boost/histogram/algorithm/reduce.hpp | 31 ++++++------------- .../boost/histogram/detail/reduce_command.hpp | 3 +- test/Jamfile | 3 +- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/include/boost/histogram/algorithm/reduce.hpp b/include/boost/histogram/algorithm/reduce.hpp index 314404541..127b0247f 100644 --- a/include/boost/histogram/algorithm/reduce.hpp +++ b/include/boost/histogram/algorithm/reduce.hpp @@ -359,16 +359,8 @@ inline reduce_command pick(unsigned iaxis, std::vector indices Command is applied to corresponding axis in order of reduce arguments. - Picking selects an arbitrary subset of bins by index. The new axis consists of the - picked bins in the order in which the indices are given, which may differ from their - order in the original axis. In contrast to `slice`, the picked bins do not have to be - adjacent. Each index must be valid and may only appear once. - - Picking only works on axes that are not ordered, like the category axis, since - removing an arbitrary subset of bins from an ordered axis would create gaps in the - axis range. The counts in bins that were not picked are added to the overflow bin, - if it is present. If it is not present, the counts are discarded. In crop mode, the - counts in unpicked bins and in the original overflow bin are always discarded. + Picking selects an arbitrary subset of bins by index, see + pick(unsigned, std::vector, slice_mode) for details. @param indices indices of the bins to keep, must be unique. @param mode whether to behave like `shrink` or `crop` regarding removed bins. @@ -427,17 +419,14 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { if (o.crop) o.use_overflow_bin = false; return detail::static_if_c::value>( [&o](const auto& a_in) { - auto a_out = std::decay_t( - a_in, axis::pick_tag{}, o.indices.data(), - o.indices.data() + o.indices.size()); - // replace pick list with a lookup table from old to new index; // unpicked bins and the old overflow bin map to o.end.index, // the overflow bin of the new axis o.end.index = static_cast(o.indices.size()); - std::vector lut(a_in.size() + 1, o.end.index); - for (index_type k = 0; k < o.end.index; ++k) lut[o.indices[k]] = k; - o.indices = std::move(lut); - return a_out; + o.lut.assign(a_in.size() + 1, o.end.index); + for (index_type k = 0; k < o.end.index; ++k) o.lut[o.indices[k]] = k; + return std::decay_t( + a_in, axis::pick_tag{}, o.indices.data(), + o.indices.data() + o.indices.size()); }, [iaxis](const auto& a_in) { return BOOST_THROW_EXCEPTION(std::invalid_argument( @@ -508,9 +497,9 @@ Histogram reduce(const Histogram& hist, const Iterable& options) { for (auto j : x.indices()) { if (o->range == reduce_command::range_t::indices_list) { - // pick: o->indices is a lookup table from old to new index; unpicked bins - // and flow bins map to o->end.index, the overflow bin of the new axis - *i = j < 0 ? o->end.index : o->indices[j]; + // pick: unpicked bins and flow bins map to o->end.index, the overflow + // bin of the new axis + *i = j < 0 ? o->end.index : o->lut[j]; if (*i == o->end.index && !o->use_overflow_bin) skip = true; } else { *i = (j - o->begin.index); diff --git a/include/boost/histogram/detail/reduce_command.hpp b/include/boost/histogram/detail/reduce_command.hpp index 59a2169eb..cd4eff3e5 100644 --- a/include/boost/histogram/detail/reduce_command.hpp +++ b/include/boost/histogram/detail/reduce_command.hpp @@ -32,10 +32,11 @@ struct reduce_command { axis::index_type index; double value; } begin{0}, end{0}; - std::vector indices; // only used by range_t::indices_list + std::vector indices; // set by the pick command unsigned merge = 0; // default value indicates unset option bool crop = false; // for internal use by the reduce algorithm + std::vector lut; // pick: maps old bin index to new bin index bool is_ordered = true; bool use_underflow_bin = true; bool use_overflow_bin = true; diff --git a/test/Jamfile b/test/Jamfile index 57b8d0d5f..c156e1240 100644 --- a/test/Jamfile +++ b/test/Jamfile @@ -63,8 +63,7 @@ alias cxx14 : [ run accumulators_weighted_sum_test.cpp ] [ run accumulators_collector_test.cpp ] [ run algorithm_project_test.cpp ] - [ run algorithm_reduce_test.cpp : : : - msvc:/bigobj ] + [ run algorithm_reduce_test.cpp ] [ run algorithm_sum_test.cpp ] [ run algorithm_empty_test.cpp ] [ run axis_boolean_test.cpp ]