Skip to main content

polydat_nodes/sampling/
alias.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Vose's alias method for O(1) sampling from discrete distributions.
5//!
6//! Given N outcomes with associated weights, an alias table pre-computes
7//! a structure that allows selecting an outcome in constant time from a
8//! single uniform u64 input.
9//!
10//! Two variants:
11//! - [`AliasTable<T>`]: generic, works with any `Clone` outcome type.
12//! - [`AliasTableU64`]: flat parallel arrays, optimized for the Phase 2
13//!   compiled kernel path where outcomes are indices 0..N.
14
15use std::collections::VecDeque;
16
17// -----------------------------------------------------------------
18// Generic alias table
19// -----------------------------------------------------------------
20
21struct AliasSlot<T> {
22    bias: f64,
23    primary: T,
24    alias: T,
25}
26
27/// Generic alias table for O(1) weighted sampling.
28///
29/// Construct with [`AliasTable::from_weights`] or [`AliasTable::uniform`],
30/// then sample with [`AliasTable::sample`].
31pub struct AliasTable<T> {
32    slots: Vec<AliasSlot<T>>,
33}
34
35impl<T: Clone> AliasTable<T> {
36    /// Build an alias table from outcomes and their weights.
37    ///
38    /// Weights do not need to be normalized — they are scaled
39    /// internally. All weights must be non-negative; at least one
40    /// must be positive.
41    pub fn from_weights(outcomes: &[T], weights: &[f64]) -> Self {
42        assert_eq!(
43            outcomes.len(),
44            weights.len(),
45            "outcomes and weights must have equal length"
46        );
47        let n = outcomes.len();
48        assert!(n > 0, "must have at least one outcome");
49
50        let sum: f64 = weights.iter().sum();
51        assert!(sum > 0.0, "total weight must be positive");
52
53        // Normalize so weights sum to N
54        let scale = n as f64 / sum;
55        let mut scaled: Vec<f64> = weights.iter().map(|w| w * scale).collect();
56
57        // Partition into small and large queues
58        let mut small: VecDeque<usize> = VecDeque::new();
59        let mut large: VecDeque<usize> = VecDeque::new();
60        for (i, &w) in scaled.iter().enumerate() {
61            if w < 1.0 {
62                small.push_back(i);
63            } else {
64                large.push_back(i);
65            }
66        }
67
68        // Build slots
69        let mut slots: Vec<AliasSlot<T>> = (0..n)
70            .map(|i| AliasSlot {
71                bias: 1.0,
72                primary: outcomes[i].clone(),
73                alias: outcomes[i].clone(),
74            })
75            .collect();
76
77        while let (Some(s), Some(l)) = (small.pop_front(), large.pop_front()) {
78            slots[s].bias = scaled[s];
79            slots[s].alias = outcomes[l].clone();
80
81            scaled[l] -= 1.0 - scaled[s];
82            if scaled[l] < 1.0 {
83                small.push_back(l);
84            } else {
85                large.push_back(l);
86            }
87        }
88
89        // Remaining items (due to floating-point drift) are their own alias
90        for &i in small.iter().chain(large.iter()) {
91            slots[i].bias = 1.0;
92        }
93
94        Self { slots }
95    }
96
97    /// Build a uniform alias table (all outcomes equally weighted).
98    pub fn uniform(outcomes: &[T]) -> Self {
99        let weights = vec![1.0; outcomes.len()];
100        Self::from_weights(outcomes, &weights)
101    }
102
103    /// Sample an outcome from a uniform u64 input.
104    ///
105    /// The input is split into two independent parts: the low bits
106    /// select a slot, the high bits determine the bias test. This
107    /// avoids correlation between slot selection and the bias coin
108    /// flip.
109    #[inline]
110    pub fn sample(&self, input: u64) -> &T {
111        let n = self.slots.len();
112        let slot_idx = (input as usize) % n;
113        // Use upper bits for the bias test (independent of slot selection)
114        let frac = (input >> 32) as f64 / u32::MAX as f64;
115        let slot = &self.slots[slot_idx];
116        if frac < slot.bias {
117            &slot.primary
118        } else {
119            &slot.alias
120        }
121    }
122
123    /// Number of outcomes in the table.
124    pub fn len(&self) -> usize {
125        self.slots.len()
126    }
127
128    /// Whether the table has no outcomes.
129    pub fn is_empty(&self) -> bool {
130        self.slots.is_empty()
131    }
132}
133
134// -----------------------------------------------------------------
135// u64-specialized alias table (flat parallel arrays)
136// -----------------------------------------------------------------
137
138/// Alias table optimized for u64 outcomes (indices 0..N).
139///
140/// Uses three parallel arrays for cache-friendly access. Suitable
141/// for the Phase 2 compiled kernel path.
142pub struct AliasTableU64 {
143    biases: Vec<f64>,
144    primaries: Vec<u64>,
145    aliases: Vec<u64>,
146}
147
148impl AliasTableU64 {
149    /// Build from weights. Outcomes are implicitly 0..N.
150    pub fn from_weights(weights: &[f64]) -> Self {
151        let n = weights.len();
152        assert!(n > 0, "must have at least one outcome");
153
154        let sum: f64 = weights.iter().sum();
155        assert!(sum > 0.0, "total weight must be positive");
156
157        let scale = n as f64 / sum;
158        let mut scaled: Vec<f64> = weights.iter().map(|w| w * scale).collect();
159
160        let mut small: VecDeque<usize> = VecDeque::new();
161        let mut large: VecDeque<usize> = VecDeque::new();
162        for (i, &w) in scaled.iter().enumerate() {
163            if w < 1.0 {
164                small.push_back(i);
165            } else {
166                large.push_back(i);
167            }
168        }
169
170        let mut biases = vec![1.0f64; n];
171        let primaries: Vec<u64> = (0..n as u64).collect();
172        let mut aliases: Vec<u64> = (0..n as u64).collect();
173
174        while let (Some(s), Some(l)) = (small.pop_front(), large.pop_front()) {
175            biases[s] = scaled[s];
176            aliases[s] = l as u64;
177
178            scaled[l] -= 1.0 - scaled[s];
179            if scaled[l] < 1.0 {
180                small.push_back(l);
181            } else {
182                large.push_back(l);
183            }
184        }
185
186        for &i in small.iter().chain(large.iter()) {
187            biases[i] = 1.0;
188        }
189
190        Self {
191            biases,
192            primaries,
193            aliases,
194        }
195    }
196
197    /// Build a uniform table (all outcomes equally weighted).
198    pub fn uniform(n: usize) -> Self {
199        Self::from_weights(&vec![1.0; n])
200    }
201
202    /// Sample an outcome index from a uniform u64 input.
203    ///
204    /// Low bits select the slot, high bits test the bias.
205    #[inline]
206    pub fn sample(&self, input: u64) -> u64 {
207        let n = self.biases.len();
208        let slot_idx = (input as usize) % n;
209        let frac = (input >> 32) as f64 / u32::MAX as f64;
210        if frac < self.biases[slot_idx] {
211            self.primaries[slot_idx]
212        } else {
213            self.aliases[slot_idx]
214        }
215    }
216
217    /// Number of outcomes in the table.
218    pub fn len(&self) -> usize {
219        self.biases.len()
220    }
221
222    /// Whether the table has no outcomes.
223    pub fn is_empty(&self) -> bool {
224        self.biases.is_empty()
225    }
226
227    /// Access the bias array (for compiled kernel closure capture).
228    pub fn biases(&self) -> &[f64] {
229        &self.biases
230    }
231
232    /// Access the primary outcome array.
233    pub fn primaries(&self) -> &[u64] {
234        &self.primaries
235    }
236
237    /// Access the alias outcome array.
238    pub fn aliases(&self) -> &[u64] {
239        &self.aliases
240    }
241}
242
243// -----------------------------------------------------------------
244// Polydat node wrapping the alias table
245// -----------------------------------------------------------------
246//
247// `AliasSample` flows through the `Const<Vec<f64>>` workload-list
248// combinator (weights) and the `#[poly_const]` setup pattern
249// (cached `AliasTableU64`). The
250// node is JIT-ineligible by the Const<Vec<_>> design — the JIT
251// u64 buffer has no slot shape for the variable-length table
252// captured in the struct field.
253
254use polydat::derive_support::PolydatSetup;
255
256impl PolydatSetup for AliasTableU64 {}
257
258/// Build the alias table from raw weights. Single-call setup
259/// invoked by the macro at construction time.
260fn build_alias_table(weights: &[f64]) -> AliasTableU64 {
261    AliasTableU64::from_weights(weights)
262}
263
264/// Sample an outcome index from a pre-built alias table over
265/// `weights`. The input is a uniform u64 (hash upstream for
266/// pseudo-random dispersion); the output is the chosen outcome
267/// index.
268#[polydat::polydat_node(category = Probability)]
269fn alias_sample(
270    input: u64,
271    weights: Const<Vec<f64>>,
272    #[poly_const(build_alias_table, from = weights)] table: &AliasTableU64,
273) -> u64 {
274    let _ = weights;
275    table.sample(input)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use polydat::ast::Value;
282
283    #[test]
284    fn uniform_table_all_outcomes_reachable() {
285        use xxhash_rust::xxh3::xxh3_64;
286
287        let table = AliasTableU64::uniform(4);
288        let mut seen = [false; 4];
289        for i in 0..10_000u64 {
290            let hashed = xxh3_64(&i.to_le_bytes());
291            let outcome = table.sample(hashed) as usize;
292            assert!(outcome < 4, "outcome {outcome} out of range");
293            seen[outcome] = true;
294        }
295        for (i, &s) in seen.iter().enumerate() {
296            assert!(s, "outcome {i} was never sampled");
297        }
298    }
299
300    #[test]
301    fn weighted_table_respects_distribution() {
302        use xxhash_rust::xxh3::xxh3_64;
303
304        // Heavily weighted: outcome 0 should dominate.
305        // Inputs must be well-distributed (hashed), matching how the
306        // Polydat uses alias tables — hash is always upstream.
307        let table = AliasTableU64::from_weights(&[100.0, 1.0, 1.0]);
308        let mut counts = [0u64; 3];
309        let n = 100_000u64;
310        for i in 0..n {
311            let hashed = xxh3_64(&i.to_le_bytes());
312            counts[table.sample(hashed) as usize] += 1;
313        }
314        // Outcome 0 has weight 100/102 ≈ 98%
315        let ratio = counts[0] as f64 / n as f64;
316        assert!(
317            ratio > 0.90,
318            "expected outcome 0 to dominate, got ratio {ratio} (counts: {counts:?})"
319        );
320    }
321
322    #[test]
323    fn deterministic() {
324        let table = AliasTableU64::from_weights(&[1.0, 2.0, 3.0]);
325        let a = table.sample(42);
326        let b = table.sample(42);
327        assert_eq!(a, b, "same input must produce same output");
328    }
329
330    #[test]
331    fn generic_table_strings() {
332        use xxhash_rust::xxh3::xxh3_64;
333
334        let outcomes = vec!["alpha", "beta", "gamma"];
335        let weights = vec![1.0, 1.0, 1.0];
336        let table = AliasTable::from_weights(&outcomes, &weights);
337        let mut seen = [false; 3];
338        for i in 0..10_000u64 {
339            let hashed = xxh3_64(&i.to_le_bytes());
340            let result = *table.sample(hashed);
341            match result {
342                "alpha" => seen[0] = true,
343                "beta" => seen[1] = true,
344                "gamma" => seen[2] = true,
345                other => panic!("unexpected outcome: {other}"),
346            }
347        }
348        for (i, &s) in seen.iter().enumerate() {
349            assert!(s, "outcome {i} never seen");
350        }
351    }
352
353    #[test]
354    fn polydat_node_eval() {
355        use polydat::ast::PolydatNode;
356        let node = AliasSample::new(vec![1.0, 1.0, 1.0, 1.0]);
357        let mut out = [Value::None];
358        node.eval(&[Value::U64(42)], &mut out);
359        assert!(out[0].as_u64() < 4);
360    }
361
362    // `alias_sample` is JIT-ineligible by the
363    // `Const<Vec<C>>` design; the typed-eval path above covers
364    // correctness. A future `compiled_u64_override` could
365    // reinstate the closure form if perf demands it.
366
367    #[test]
368    fn single_outcome() {
369        let table = AliasTableU64::from_weights(&[1.0]);
370        for i in 0..1000 {
371            assert_eq!(table.sample(i), 0);
372        }
373    }
374
375    #[test]
376    fn two_outcomes_50_50() {
377        use xxhash_rust::xxh3::xxh3_64;
378
379        let table = AliasTableU64::from_weights(&[1.0, 1.0]);
380        let mut counts = [0u64; 2];
381        let n = 100_000u64;
382        for i in 0..n {
383            let hashed = xxh3_64(&i.to_le_bytes());
384            counts[table.sample(hashed) as usize] += 1;
385        }
386        let ratio = counts[0] as f64 / n as f64;
387        assert!(
388            (0.40..0.60).contains(&ratio),
389            "expected ~50/50, got ratio {ratio}"
390        );
391    }
392}