Skip to main content

chromy_core/
select.rs

1//! The full procedure: a random head, the rotated list, the selection.
2//!
3//! Run from a fixed first unit, a sequential procedure gives the units
4//! near the head a subtly different treatment from the units near the
5//! tail. Chromy's remedy, kept by SAS, is to draw the head itself with
6//! probability proportional to size, rotate the list so it starts there,
7//! and only then select; the procedure becomes invariant to where the
8//! frame happened to begin.
9//!
10//! The head draw is the one point where this crate does not consume a
11//! uniform the same way everywhere: a binding that wants to reproduce its
12//! host language's sampler (R's `sample.int`, say) should draw the head
13//! itself and call [`select_from`]; [`select`] draws it by cumulative
14//! inversion from a single uniform.
15
16use crate::error::Error;
17use crate::pmr::{pmr, Selection};
18
19/// Which units may be drawn as the head of the list.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Head {
22    /// Every unit: the selection randomises all hits.
23    All,
24    /// Only units with a fractional expected hit: integer hits are taken
25    /// as fixed and only the remainder is randomised.
26    Residual,
27}
28
29/// The outcome of a full selection.
30#[derive(Debug, Clone)]
31pub struct Selected {
32    /// Where the rotated list starts: a 0-based position in `order`.
33    pub head: usize,
34    /// The frame units in selection order: `order` rotated to the head.
35    pub list: Vec<usize>,
36    /// Realised hits, one per position of `list`.
37    pub hits: Vec<u64>,
38    /// Effective expected hits per *frame unit* (original indexing),
39    /// after any floating-point adjustment.
40    pub effective_lambda: Vec<f64>,
41    /// The inner selection's accounting, when one was run; `None` when
42    /// every expected hit was an integer and nothing was random.
43    pub accounting: Option<Selection>,
44}
45
46fn check_order(order: &[usize], n_units: usize) -> Result<(), Error> {
47    if order.len() != n_units {
48        return Err(Error::Input(format!(
49            "{} expected hits but an order of {} positions",
50            n_units,
51            order.len()
52        )));
53    }
54    let mut seen = vec![false; n_units];
55    for &u in order {
56        if u >= n_units || seen[u] {
57            return Err(Error::Input(
58                "the order must be a permutation of the frame".into(),
59            ));
60        }
61        seen[u] = true;
62    }
63    Ok(())
64}
65
66/// The list positions eligible to be the head, in list order.
67pub fn eligible_positions(lambda: &[f64], order: &[usize], head: Head) -> Vec<usize> {
68    match head {
69        Head::All => (0..order.len()).collect(),
70        Head::Residual => (0..order.len())
71            .filter(|&p| lambda[order[p]] != lambda[order[p]].floor())
72            .collect(),
73    }
74}
75
76/// How many uniforms [`select_from`] will consume, beyond the head.
77pub fn required_uniforms(lambda: &[f64], order: &[usize], head: Head) -> usize {
78    match head {
79        Head::All => order.len(),
80        Head::Residual => eligible_positions(lambda, order, head).len(),
81    }
82}
83
84/// Draw a position by cumulative inversion: the first weight whose running
85/// sum exceeds `u` times the total.
86pub fn head_position(weights: &[f64], u: f64) -> Result<usize, Error> {
87    if !(0.0..1.0).contains(&u) {
88        return Err(Error::Input(format!(
89            "the head uniform must lie in [0, 1); it is {u}"
90        )));
91    }
92    let mut total = 0.0_f64;
93    for &w in weights {
94        if !w.is_finite() || w < 0.0 {
95            return Err(Error::Input(format!(
96                "head weights must be finite and non-negative; found {w}"
97            )));
98        }
99        total += w;
100    }
101    if total <= 0.0 {
102        return Err(Error::Input("the head weights sum to zero".into()));
103    }
104    let target = u * total;
105    let mut cum = 0.0_f64;
106    for (p, &w) in weights.iter().enumerate() {
107        cum += w;
108        if cum > target {
109            return Ok(p);
110        }
111    }
112    Ok(weights.len() - 1)
113}
114
115/// Select with the head already drawn.
116///
117/// `start` is a 0-based position in `order` — one of the
118/// [`eligible_positions`] — from the caller's own draw. `uniforms` feeds
119/// the sequential selection, [`required_uniforms`] of them: one per unit
120/// under [`Head::All`], one per fractional unit under [`Head::Residual`].
121pub fn select_from(
122    lambda: &[f64],
123    order: &[usize],
124    start: usize,
125    uniforms: &[f64],
126    head: Head,
127) -> Result<Selected, Error> {
128    let n_units = lambda.len();
129    check_order(order, n_units)?;
130
131    let eligible = eligible_positions(lambda, order, head);
132    if eligible.is_empty() {
133        // Every expected hit is an integer: the selection is the expected
134        // hits themselves, and nothing is random.
135        let hits = order
136            .iter()
137            .map(|&u| {
138                if lambda[u] < 0.0 || !lambda[u].is_finite() {
139                    Err(Error::Input(format!(
140                        "expected hits must be finite and non-negative; unit {u} \
141                         has {}",
142                        lambda[u]
143                    )))
144                } else {
145                    Ok(lambda[u] as u64)
146                }
147            })
148            .collect::<Result<Vec<_>, _>>()?;
149        return Ok(Selected {
150            head: 0,
151            list: order.to_vec(),
152            hits,
153            effective_lambda: lambda.to_vec(),
154            accounting: None,
155        });
156    }
157    if !eligible.contains(&start) {
158        return Err(Error::Input(format!(
159            "the head position {start} is not eligible"
160        )));
161    }
162
163    // Rotate so the list starts at the head; `positions` remembers where
164    // each listed unit sat in the unrotated order, which is the priority
165    // the drift correction respects.
166    let positions: Vec<usize> = (start..n_units).chain(0..start).collect();
167    let list: Vec<usize> = positions.iter().map(|&p| order[p]).collect();
168
169    let mut effective_lambda = lambda.to_vec();
170    let (hits, accounting) = match head {
171        Head::All => {
172            let listed: Vec<f64> = list.iter().map(|&u| lambda[u]).collect();
173            let selection = pmr(&listed, uniforms, &positions)?;
174            for (i, &u) in list.iter().enumerate() {
175                effective_lambda[u] = selection.effective_lambda[i];
176            }
177            (selection.hits.clone(), selection)
178        }
179        Head::Residual => {
180            let fractional: Vec<usize> = (0..n_units)
181                .filter(|&i| lambda[list[i]] != lambda[list[i]].floor())
182                .collect();
183            let listed: Vec<f64> = fractional.iter().map(|&i| lambda[list[i]]).collect();
184            let priority: Vec<usize> = fractional.iter().map(|&i| positions[i]).collect();
185            let selection = pmr(&listed, uniforms, &priority)?;
186            let mut hits = vec![0_u64; n_units];
187            for i in 0..n_units {
188                if lambda[list[i]] == lambda[list[i]].floor() {
189                    hits[i] = lambda[list[i]] as u64;
190                }
191            }
192            for (s, &i) in fractional.iter().enumerate() {
193                hits[i] = selection.hits[s];
194                effective_lambda[list[i]] = selection.effective_lambda[s];
195            }
196            (hits, selection)
197        }
198    };
199
200    Ok(Selected {
201        head: start,
202        list,
203        hits,
204        effective_lambda,
205        accounting: Some(accounting),
206    })
207}
208
209/// Select with the head drawn here, by cumulative inversion.
210///
211/// `u_head` draws the head among the eligible positions with probability
212/// proportional to expected hits; `uniforms` feeds the selection as in
213/// [`select_from`]. A binding that must reproduce its host language's own
214/// head sampler should call [`select_from`] instead.
215pub fn select(
216    lambda: &[f64],
217    order: &[usize],
218    head: Head,
219    u_head: f64,
220    uniforms: &[f64],
221) -> Result<Selected, Error> {
222    check_order(order, lambda.len())?;
223    let eligible = eligible_positions(lambda, order, head);
224    if eligible.is_empty() {
225        return select_from(lambda, order, 0, uniforms, head);
226    }
227    let weights: Vec<f64> = eligible.iter().map(|&p| lambda[order[p]]).collect();
228    let start = eligible[head_position(&weights, u_head)?];
229    select_from(lambda, order, start, uniforms, head)
230}