dasmig-core 1.2.0
Shared random engine, distributions and data tiers for the dasmig generator libraries
Loading...
Searching...
No Matches
weighted_table.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2020-2026 Diego Dasso Migotto
3
4#ifndef DASMIG_CORE_WEIGHTED_TABLE_HPP
5#define DASMIG_CORE_WEIGHTED_TABLE_HPP
6
7#include "distributions.hpp"
8#include "rng.hpp"
9
10#include <algorithm>
11#include <array>
12#include <cmath>
13#include <concepts>
14#include <cstddef>
15#include <initializer_list>
16#include <optional>
17#include <span>
18#include <stdexcept>
19#include <utility>
20#include <vector>
21
22/// @file weighted_table.hpp
23/// @brief Weighted choice among values, with per-call conditional weights
24/// and exclusions.
25/// @author Diego Dasso Migotto (diegomigotto at hotmail dot com)
26
27namespace dasmig::core
28{
29
30/// @brief Values with weights, picked with probability proportional to the
31/// weight.
32///
33/// A pick uses the same algorithm as discrete (cumulative sums in entry
34/// order, one engine value), so entry order is part of the output.
35///
36/// Weights can be adjusted per call by a function of the entry and its base
37/// weight, which is how context conditions a choice: return the base weight
38/// to keep it, a multiple to favour it, or 0 to exclude it.
39///
40/// @code
41/// dasmig::core::weighted_table<std::string> jobs{{"farmer", 3.0}, {"nurse", 2.0}, {"pilot", 0.2}};
42/// const auto& job = jobs.pick(r);
43/// const auto& adult_job = jobs.pick(r, [&](const std::string& j, double w) {
44/// return (j == "pilot" && age < 21) ? 0.0 : w;
45/// });
46/// @endcode
47///
48/// @tparam T Value type.
49template <typename T>
51{
52 public:
53 /// @brief A value and its base weight.
54 struct entry
55 {
56 T value; ///< The value.
57 double weight; ///< Base weight, finite and >= 0.
58 };
59
60 /// @brief An empty table.
61 weighted_table() = default;
62
63 /// @brief A table from entries.
64 /// @param entries Values and weights.
65 /// @throws std::invalid_argument If a weight is negative or not finite.
66 weighted_table(std::initializer_list<entry> entries)
67 {
68 for (const auto& e : entries) add(e.value, e.weight);
69 }
70
71 /// @brief Append an entry.
72 /// @param value The value.
73 /// @param weight Base weight, finite and >= 0.
74 /// @return `*this`.
75 /// @throws std::invalid_argument If @p weight is negative or not finite,
76 /// or the total weight overflows.
77 weighted_table& add(T value, double weight)
78 {
79 check_weight(weight);
80 const double total = (_cumulative.empty() ? 0.0 : _cumulative.back()) + weight;
81 if (!std::isfinite(total))
82 {
83 throw std::invalid_argument("weighted_table: total weight overflows");
84 }
85 if (weight > 0.0) _last_positive = _entries.size();
86 _entries.push_back({.value = std::move(value), .weight = weight});
87 _cumulative.push_back(total);
88 return *this;
89 }
90
91 /// @brief Number of entries.
92 /// @return The size.
93 [[nodiscard]] std::size_t size() const noexcept { return _entries.size(); }
94
95 /// @brief Whether there are no entries.
96 /// @return True when empty.
97 [[nodiscard]] bool empty() const noexcept { return _entries.empty(); }
98
99 /// @brief The entries, in order.
100 /// @return The entries.
101 [[nodiscard]] std::span<const entry> entries() const noexcept { return _entries; }
102
103 /// @brief Sum of the base weights.
104 /// @return The total.
105 [[nodiscard]] double total_weight() const noexcept
106 {
107 return _cumulative.empty() ? 0.0 : _cumulative.back();
108 }
109
110 /// @brief Pick an index by base weight. Consumes one engine value.
111 /// @param r Engine.
112 /// @return The index of the chosen entry.
113 /// @throws std::invalid_argument If no entry has a positive weight.
114 [[nodiscard]] std::size_t pick_index(rng& r) const
115 {
116 if (!(total_weight() > 0.0))
117 {
118 throw std::invalid_argument("weighted_table: no entry has a positive weight");
119 }
120 return detail::pick_cumulative(r, _cumulative, _last_positive);
121 }
122
123 /// @brief Pick a value by base weight. Consumes one engine value.
124 /// @param r Engine.
125 /// @return The chosen value.
126 /// @throws std::invalid_argument If no entry has a positive weight.
127 [[nodiscard]] const T& pick(rng& r) const { return _entries[pick_index(r)].value; }
128
129 /// @brief Pick an index with adjusted weights, or nothing when every
130 /// adjusted weight is 0. Always consumes one engine value.
131 /// @tparam F Callable `double(const T& value, double base_weight)`.
132 /// @param r Engine.
133 /// @param adjust Returns the weight to use (0 excludes the entry).
134 /// @return The index, or empty.
135 /// @throws std::invalid_argument If @p adjust returns a negative or
136 /// non-finite weight, or the adjusted total overflows.
137 template <std::invocable<const T&, double> F>
138 [[nodiscard]] std::optional<std::size_t> try_pick_index(rng& r, const F& adjust) const
139 {
140 // The sums of a small table stay on the stack: an allocation would
141 // cost more than the pick.
142 std::array<double, inline_sums_> inline_sums; // NOLINT(cppcoreguidelines-pro-type-member-init,hicpp-member-init): written before read
143 std::vector<double> heap_sums;
144 std::span<double> cumulative{inline_sums.data(), std::min(_entries.size(), inline_sums_)};
145 if (_entries.size() > inline_sums_)
146 {
147 heap_sums.resize(_entries.size());
148 cumulative = heap_sums;
149 }
150 std::size_t last_positive = 0;
151 double total = 0.0;
152 for (std::size_t i = 0; i < _entries.size(); ++i)
153 {
154 const auto w = static_cast<double>(adjust(_entries[i].value, _entries[i].weight));
155 check_weight(w);
156 total += w;
157 if (w > 0.0) last_positive = i;
158 cumulative[i] = total;
159 }
160 if (!std::isfinite(total))
161 {
162 throw std::invalid_argument("weighted_table: adjusted total weight overflows");
163 }
164 if (!(total > 0.0))
165 {
166 (void)r(); // keep the stream position independent of the outcome
167 return std::nullopt;
168 }
169 return detail::pick_cumulative(r, cumulative, last_positive);
170 }
171
172 /// @brief Pick a value with adjusted weights, or nothing when every
173 /// adjusted weight is 0. Always consumes one engine value.
174 /// @tparam F As try_pick_index().
175 /// @param r Engine.
176 /// @param adjust Returns the weight to use (0 excludes the entry).
177 /// @return A pointer to the chosen value, or nullptr.
178 /// @throws std::invalid_argument As try_pick_index().
179 template <std::invocable<const T&, double> F>
180 [[nodiscard]] const T* try_pick(rng& r, const F& adjust) const
181 {
182 const auto i = try_pick_index(r, adjust);
183 return i ? &_entries[*i].value : nullptr;
184 }
185
186 /// @brief Pick a value with adjusted weights. Consumes one engine value.
187 /// @tparam F As try_pick_index().
188 /// @param r Engine.
189 /// @param adjust Returns the weight to use (0 excludes the entry).
190 /// @return The chosen value.
191 /// @throws std::invalid_argument If every adjusted weight is 0, or as
192 /// try_pick_index().
193 template <std::invocable<const T&, double> F>
194 [[nodiscard]] const T& pick(rng& r, const F& adjust) const
195 {
196 const T* chosen = try_pick(r, adjust);
197 if (chosen == nullptr)
198 {
199 throw std::invalid_argument("weighted_table: every adjusted weight is 0");
200 }
201 return *chosen;
202 }
203
204 /// @brief Pick up to @p k distinct indices, each draw by adjusted weight
205 /// among the entries not yet chosen.
206 ///
207 /// A draw where no remaining entry has a positive weight chooses nothing,
208 /// so the result can be shorter than @p k. Consumes exactly @p k engine
209 /// values either way.
210 ///
211 /// @tparam F As try_pick_index().
212 /// @param r Engine.
213 /// @param k Number of entries wanted.
214 /// @param adjust Returns the weight to use (0 excludes the entry).
215 /// @return The chosen indices, in the order drawn.
216 /// @throws std::invalid_argument As try_pick_index().
217 template <std::invocable<const T&, double> F>
218 [[nodiscard]] std::vector<std::size_t> pick_distinct_indices(rng& r, std::size_t k,
219 const F& adjust) const
220 {
221 std::vector<std::size_t> chosen;
222 std::vector<bool> taken(_entries.size(), false);
223 for (std::size_t n = 0; n < k; ++n)
224 {
225 std::size_t index = 0;
226 const auto i = try_pick_index(r, [&](const T& value, double w) {
227 const bool skip = taken[index++];
228 return skip ? 0.0 : static_cast<double>(adjust(value, w));
229 });
230 if (!i) continue; // keep consuming one value per requested pick
231 taken[*i] = true;
232 chosen.push_back(*i);
233 }
234 return chosen;
235 }
236
237 /// @brief Pick up to @p k distinct indices by base weight.
238 /// @param r Engine.
239 /// @param k Number of entries wanted.
240 /// @return The chosen indices, in the order drawn.
241 [[nodiscard]] std::vector<std::size_t> pick_distinct_indices(rng& r, std::size_t k) const
242 {
243 return pick_distinct_indices(r, k, [](const T&, double w) { return w; });
244 }
245
246 private:
247 static void check_weight(double w)
248 {
249 if (!std::isfinite(w) || w < 0.0)
250 {
251 throw std::invalid_argument("weighted_table: weights must be finite and >= 0");
252 }
253 }
254
255 std::vector<entry> _entries;
256 std::vector<double> _cumulative;
257
258 // Entries whose adjusted sums try_pick_index() keeps on the stack.
259 static constexpr std::size_t inline_sums_ = 64;
260 std::size_t _last_positive{0};
261};
262
263} // namespace dasmig::core
264
265#endif // DASMIG_CORE_WEIGHTED_TABLE_HPP
The core random engine: xoshiro256** (Blackman and Vigna 2018).
Definition rng.hpp:108
Values with weights, picked with probability proportional to the weight.
std::vector< std::size_t > pick_distinct_indices(rng &r, std::size_t k) const
Pick up to k distinct indices by base weight.
const T * try_pick(rng &r, const F &adjust) const
Pick a value with adjusted weights, or nothing when every adjusted weight is 0. Always consumes one e...
const T & pick(rng &r, const F &adjust) const
Pick a value with adjusted weights. Consumes one engine value.
std::size_t pick_index(rng &r) const
Pick an index by base weight. Consumes one engine value.
std::span< const entry > entries() const noexcept
The entries, in order.
bool empty() const noexcept
Whether there are no entries.
std::size_t size() const noexcept
Number of entries.
weighted_table()=default
An empty table.
std::vector< std::size_t > pick_distinct_indices(rng &r, std::size_t k, const F &adjust) const
Pick up to k distinct indices, each draw by adjusted weight among the entries not yet chosen.
weighted_table & add(T value, double weight)
Append an entry.
std::optional< std::size_t > try_pick_index(rng &r, const F &adjust) const
Pick an index with adjusted weights, or nothing when every adjusted weight is 0. Always consumes one ...
weighted_table(std::initializer_list< entry > entries)
A table from entries.
double total_weight() const noexcept
Sum of the base weights.
const T & pick(rng &r) const
Pick a value by base weight. Consumes one engine value.
Random distributions with output fixed by the core, not by the standard library.
The random engine every generator library uses: xoshiro256** seeded through SplitMix64,...
A value and its base weight.
double weight
Base weight, finite and >= 0.