chromy_core/pmr.rs
1//! The sequential selection itself: probability minimum replacement.
2//!
3//! Chromy's procedure walks the ordered list once. It carries the
4//! fractional parts of the expected hits as a running remainder; each time
5//! the remainder crosses an integer, one more selection is available, and a
6//! single conditional draw per unit decides whether that selection lands
7//! here or later. The conditional probabilities are built so that each
8//! unit's realised hits are either the floor or the ceiling of its expected
9//! hits — never further — which is what "minimum replacement" means.
10//!
11//! The arithmetic is done with compensated (Kahan) summation, and the
12//! inevitable floating-point drift is not ignored but *assigned*: a single
13//! unit absorbs the difference between the sum of expected hits and the
14//! integer sample size, and the function reports which unit, by how much,
15//! and every near-boundary crossing it had to settle. The effective
16//! expected hits it returns are the ones the selection actually used, and
17//! the ones estimation must divide by.
18
19use crate::error::Error;
20
21/// Tolerance for a remainder that lands just under an integer boundary:
22/// four units in the last place of 1.0.
23const TAU: f64 = 4.0 * f64::EPSILON;
24
25/// The outcome of a [`pmr`] selection.
26#[derive(Debug, Clone)]
27pub struct Selection {
28 /// Realised hits per unit, in list order. Always the floor or the
29 /// ceiling of the unit's effective expected hits, and summing exactly
30 /// to the sample size.
31 pub hits: Vec<u64>,
32 /// The expected hits the selection actually used, after the
33 /// floating-point drift was assigned. These are the denominators of
34 /// the sampling weights.
35 pub effective_lambda: Vec<f64>,
36 /// The integer sample size the expected hits summed to.
37 pub sample_size: u64,
38 /// The floating-point drift between the sum of expected hits and the
39 /// sample size, before it was assigned.
40 pub eps_sum: f64,
41 /// The unit (list position) that absorbed the drift, if any was needed.
42 pub corrected_unit: Option<usize>,
43 /// How many crossings sat within [`TAU`] of an integer boundary and
44 /// were settled by borrowing from the units that follow.
45 pub adjusted_crossings: usize,
46 /// The largest absolute difference between a unit's effective and
47 /// nominal expected hits.
48 pub max_adjustment: f64,
49}
50
51/// Select with probability minimum replacement down a list.
52///
53/// `lambda` holds the expected hits of each unit in list order — any
54/// non-negative values; a value above one means the unit may be selected
55/// more than once. `uniforms` holds one draw in `[0, 1)` per unit, from the
56/// caller's generator: the selection is a pure function of its arguments,
57/// so a seed in R or Python reproduces it exactly. `priority` breaks the
58/// tie when more than one unit could absorb the floating-point drift;
59/// lower wins. Pass `0..n` when indifferent.
60///
61/// The expected hits must sum to within `1e-8` of an integer — the sample
62/// size. The function refuses anything else: silently rescaling the
63/// caller's probabilities would detach the weights from the design.
64pub fn pmr(lambda: &[f64], uniforms: &[f64], priority: &[usize]) -> Result<Selection, Error> {
65 let n_units = lambda.len();
66 if uniforms.len() != n_units {
67 return Err(Error::Input(format!(
68 "{} expected hits but {} uniforms; one draw per unit",
69 n_units,
70 uniforms.len()
71 )));
72 }
73 if priority.len() != n_units {
74 return Err(Error::Input(format!(
75 "{} expected hits but {} priorities",
76 n_units,
77 priority.len()
78 )));
79 }
80 if n_units == 0 {
81 return Err(Error::Input("an empty list cannot be sampled".into()));
82 }
83 for (i, &l) in lambda.iter().enumerate() {
84 if !l.is_finite() || l < 0.0 {
85 return Err(Error::Input(format!(
86 "expected hits must be finite and non-negative; unit {i} has {l}"
87 )));
88 }
89 }
90 for (i, &u) in uniforms.iter().enumerate() {
91 if !(0.0..1.0).contains(&u) {
92 return Err(Error::Input(format!(
93 "uniforms must lie in [0, 1); draw {i} is {u}"
94 )));
95 }
96 }
97
98 // The sample size, by compensated summation: the expected hits are
99 // trusted to sum to an integer, and the drift is measured, not assumed.
100 let mut sum = 0.0_f64;
101 let mut comp = 0.0_f64;
102 for &x in lambda {
103 let t = sum + x;
104 if sum.abs() >= x.abs() {
105 comp += (sum - t) + x;
106 } else {
107 comp += (x - t) + sum;
108 }
109 sum = t;
110 }
111 let n = (sum + comp).round_ties_even();
112 let eps_sum = (sum - n) + comp;
113 if eps_sum.abs() >= 1e-8 {
114 return Err(Error::Drift { eps: eps_sum });
115 }
116 let n = n as u64;
117
118 let k: Vec<f64> = lambda.iter().map(|l| l.floor()).collect();
119 let mut r: Vec<f64> = lambda.iter().zip(&k).map(|(l, ki)| l - ki).collect();
120
121 // Assign the drift to one unit: the largest fractional part that can
122 // take it, ties broken by priority. One unit moves by eps; every other
123 // unit's probability is untouched.
124 let mut corrected_unit = None;
125 if eps_sum != 0.0 {
126 let candidate = (0..n_units)
127 .filter(|&i| {
128 if eps_sum > 0.0 {
129 r[i] > eps_sum
130 } else {
131 r[i] > 0.0 && r[i] - eps_sum < 1.0
132 }
133 })
134 .min_by(|&a, &b| {
135 r[b].partial_cmp(&r[a])
136 .unwrap()
137 .then(priority[a].cmp(&priority[b]))
138 });
139 let j = candidate.ok_or(Error::NoAbsorber { eps: eps_sum })?;
140 r[j] -= eps_sum;
141 corrected_unit = Some(j);
142 }
143
144 // The walk. `frac`/`frac_comp` carry the running remainder with its
145 // compensation; `debt` carries what a near-boundary adjustment borrowed
146 // from the units ahead; the integer state is exact in i64.
147 let mut hits_cum_floor = vec![0_i64; n_units]; // V_I: selections available before the draw
148 let mut remainder_after = vec![0.0_f64; n_units]; // V_F
149 let mut taken = vec![0_i64; n_units]; // T: selections made up to and including unit i
150 let mut hits = vec![0_i64; n_units];
151 let mut frac = 0.0_f64;
152 let mut frac_comp = 0.0_f64;
153 let mut debt = 0.0_f64;
154 let mut adjusted_crossings = 0_usize;
155
156 for i in 0..n_units {
157 let (i_prev, f_prev, t_prev) = if i == 0 {
158 (0_i64, 0.0_f64, 0_i64)
159 } else {
160 (hits_cum_floor[i - 1], remainder_after[i - 1], taken[i - 1])
161 };
162
163 let repay = debt.min(r[i]);
164 r[i] -= repay;
165 debt -= repay;
166
167 let mut s = frac + r[i];
168 let mut cs = if frac.abs() >= r[i].abs() {
169 frac_comp + ((frac - s) + r[i])
170 } else {
171 frac_comp + ((r[i] - s) + frac)
172 };
173 let mut s_eff = s + cs;
174
175 // A remainder within TAU of 1 is a crossing in all but rounding:
176 // treat it as one, and borrow the missing sliver from the units
177 // that follow, so no unit is stranded with an unusable remainder.
178 if i < n_units - 1 && (1.0 - TAU..1.0).contains(&s_eff) {
179 debt += 1.0 - s_eff;
180 r[i] += 1.0 - s_eff;
181 s = 1.0;
182 cs = 0.0;
183 s_eff = 1.0;
184 adjusted_crossings += 1;
185 }
186
187 let crossing: i64;
188 if i == n_units - 1 {
189 // The last unit closes the books: whatever remainder is left
190 // must round to its crossing, and its fractional part is
191 // rewritten so that the effective expected hits sum exactly.
192 crossing = s_eff.round_ties_even() as i64;
193 r[i] = crossing as f64 - f_prev;
194 frac = 0.0;
195 frac_comp = 0.0;
196 } else {
197 crossing = i64::from(s_eff >= 1.0);
198 frac = s - crossing as f64;
199 frac_comp = cs;
200 }
201
202 hits_cum_floor[i] = i_prev + k[i] as i64 + crossing;
203 remainder_after[i] = (frac + frac_comp).max(0.0);
204
205 // Chromy's conditional probability that the pending selection has
206 // already been taken, given the integer state so far.
207 let p_taken = if t_prev == i_prev {
208 if crossing == 0 {
209 r[i] / (1.0 - f_prev)
210 } else {
211 0.0
212 }
213 } else if crossing == 0 {
214 1.0
215 } else if f_prev > 0.0 {
216 remainder_after[i] / f_prev
217 } else {
218 0.0
219 };
220
221 taken[i] = hits_cum_floor[i] + i64::from(uniforms[i] < p_taken);
222 hits[i] = taken[i] - t_prev;
223 }
224
225 let effective_lambda: Vec<f64> = k.iter().zip(&r).map(|(ki, ri)| ki + ri).collect();
226 let lost = lambda
227 .iter()
228 .zip(&effective_lambda)
229 .filter(|&(&l, &e)| l > 0.0 && e <= 0.0)
230 .count();
231 if lost > 0 {
232 return Err(Error::LostProbability { units: lost });
233 }
234
235 let total: i64 = hits.iter().sum();
236 if total < 0 || total as u64 != n {
237 return Err(Error::Internal(format!(
238 "realised hits sum to {total}, expected {n}"
239 )));
240 }
241 // Minimum replacement itself, checked on every call: each unit's hits
242 // are the floor or the ceiling of its effective expected hits.
243 for (i, &h) in hits.iter().enumerate() {
244 let lo = effective_lambda[i].floor() as i64;
245 let hi = effective_lambda[i].ceil() as i64;
246 if h < lo || h > hi {
247 return Err(Error::Internal(format!(
248 "unit {i} realised {h} hits against expected {}; minimum \
249 replacement was violated",
250 effective_lambda[i]
251 )));
252 }
253 }
254 let hits: Vec<u64> = hits.iter().map(|&h| h as u64).collect();
255
256 let max_adjustment = lambda
257 .iter()
258 .zip(&effective_lambda)
259 .map(|(&l, &e)| (e - l).abs())
260 .fold(0.0_f64, f64::max);
261
262 Ok(Selection {
263 hits,
264 effective_lambda,
265 sample_size: n,
266 eps_sum,
267 corrected_unit,
268 adjusted_crossings,
269 max_adjustment,
270 })
271}