Skip to main content

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}