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 compiled
13//!   kernel paths (closure tier and the `jit_weighted_pick` extern)
14//!   where outcomes are indices 0..N.
15
16use std::collections::VecDeque;
17
18// -----------------------------------------------------------------
19// Generic alias table
20// -----------------------------------------------------------------
21
22struct AliasSlot<T> {
23    bias: f64,
24    primary: T,
25    alias: T,
26}
27
28/// Generic alias table for O(1) weighted sampling.
29///
30/// Construct with [`AliasTable::from_weights`] or [`AliasTable::uniform`],
31/// then sample with [`AliasTable::sample`].
32pub struct AliasTable<T> {
33    slots: Vec<AliasSlot<T>>,
34}
35
36impl<T: Clone> AliasTable<T> {
37    /// Build an alias table from outcomes and their weights.
38    ///
39    /// Weights do not need to be normalized — they are scaled
40    /// internally. All weights must be non-negative; at least one
41    /// must be positive.
42    pub fn from_weights(outcomes: &[T], weights: &[f64]) -> Self {
43        assert_eq!(
44            outcomes.len(),
45            weights.len(),
46            "outcomes and weights must have equal length"
47        );
48        let n = outcomes.len();
49        assert!(n > 0, "must have at least one outcome");
50
51        let sum: f64 = weights.iter().sum();
52        assert!(sum > 0.0, "total weight must be positive");
53
54        // Normalize so weights sum to N
55        let scale = n as f64 / sum;
56        let mut scaled: Vec<f64> = weights.iter().map(|w| w * scale).collect();
57
58        // Partition into small and large queues
59        let mut small: VecDeque<usize> = VecDeque::new();
60        let mut large: VecDeque<usize> = VecDeque::new();
61        for (i, &w) in scaled.iter().enumerate() {
62            if w < 1.0 {
63                small.push_back(i);
64            } else {
65                large.push_back(i);
66            }
67        }
68
69        // Build slots
70        let mut slots: Vec<AliasSlot<T>> = (0..n)
71            .map(|i| AliasSlot {
72                bias: 1.0,
73                primary: outcomes[i].clone(),
74                alias: outcomes[i].clone(),
75            })
76            .collect();
77
78        while let (Some(s), Some(l)) = (small.pop_front(), large.pop_front()) {
79            slots[s].bias = scaled[s];
80            slots[s].alias = outcomes[l].clone();
81
82            scaled[l] -= 1.0 - scaled[s];
83            if scaled[l] < 1.0 {
84                small.push_back(l);
85            } else {
86                large.push_back(l);
87            }
88        }
89
90        // Remaining items (due to floating-point drift) are their own alias
91        for &i in small.iter().chain(large.iter()) {
92            slots[i].bias = 1.0;
93        }
94
95        Self { slots }
96    }
97
98    /// Build a uniform alias table (all outcomes equally weighted).
99    pub fn uniform(outcomes: &[T]) -> Self {
100        let weights = vec![1.0; outcomes.len()];
101        Self::from_weights(outcomes, &weights)
102    }
103
104    /// Sample an outcome from a uniform u64 input.
105    ///
106    /// The input is split into two independent parts: the low bits
107    /// select a slot, the high bits determine the bias test. This
108    /// avoids correlation between slot selection and the bias coin
109    /// flip.
110    #[inline]
111    pub fn sample(&self, input: u64) -> &T {
112        let n = self.slots.len();
113        let slot_idx = (input as usize) % n;
114        // Use upper bits for the bias test (independent of slot selection)
115        let frac = (input >> 32) as f64 / u32::MAX as f64;
116        let slot = &self.slots[slot_idx];
117        if frac < slot.bias {
118            &slot.primary
119        } else {
120            &slot.alias
121        }
122    }
123
124    /// Number of outcomes in the table.
125    pub fn len(&self) -> usize {
126        self.slots.len()
127    }
128
129    /// Whether the table has no outcomes.
130    pub fn is_empty(&self) -> bool {
131        self.slots.is_empty()
132    }
133}
134
135// -----------------------------------------------------------------
136// u64-specialized alias table (flat parallel arrays)
137// -----------------------------------------------------------------
138
139/// Alias table optimized for u64 outcomes (indices 0..N).
140///
141/// Uses three parallel arrays for cache-friendly access. Suitable
142/// for the Phase 2 compiled kernel path.
143pub struct AliasTableU64 {
144    biases: Vec<f64>,
145    primaries: Vec<u64>,
146    aliases: Vec<u64>,
147}
148
149impl AliasTableU64 {
150    /// Build from weights. Outcomes are implicitly 0..N.
151    pub fn from_weights(weights: &[f64]) -> Self {
152        let n = weights.len();
153        assert!(n > 0, "must have at least one outcome");
154
155        let sum: f64 = weights.iter().sum();
156        assert!(sum > 0.0, "total weight must be positive");
157
158        let scale = n as f64 / sum;
159        let mut scaled: Vec<f64> = weights.iter().map(|w| w * scale).collect();
160
161        let mut small: VecDeque<usize> = VecDeque::new();
162        let mut large: VecDeque<usize> = VecDeque::new();
163        for (i, &w) in scaled.iter().enumerate() {
164            if w < 1.0 {
165                small.push_back(i);
166            } else {
167                large.push_back(i);
168            }
169        }
170
171        let mut biases = vec![1.0f64; n];
172        let primaries: Vec<u64> = (0..n as u64).collect();
173        let mut aliases: Vec<u64> = (0..n as u64).collect();
174
175        while let (Some(s), Some(l)) = (small.pop_front(), large.pop_front()) {
176            biases[s] = scaled[s];
177            aliases[s] = l as u64;
178
179            scaled[l] -= 1.0 - scaled[s];
180            if scaled[l] < 1.0 {
181                small.push_back(l);
182            } else {
183                large.push_back(l);
184            }
185        }
186
187        for &i in small.iter().chain(large.iter()) {
188            biases[i] = 1.0;
189        }
190
191        Self {
192            biases,
193            primaries,
194            aliases,
195        }
196    }
197
198    /// Build a uniform table (all outcomes equally weighted).
199    pub fn uniform(n: usize) -> Self {
200        Self::from_weights(&vec![1.0; n])
201    }
202
203    /// Sample an outcome index from a uniform u64 input.
204    ///
205    /// Low bits select the slot, high bits test the bias.
206    #[inline]
207    pub fn sample(&self, input: u64) -> u64 {
208        let n = self.biases.len();
209        let slot_idx = (input as usize) % n;
210        let frac = (input >> 32) as f64 / u32::MAX as f64;
211        if frac < self.biases[slot_idx] {
212            self.primaries[slot_idx]
213        } else {
214            self.aliases[slot_idx]
215        }
216    }
217
218    /// Number of outcomes in the table.
219    pub fn len(&self) -> usize {
220        self.biases.len()
221    }
222
223    /// Whether the table has no outcomes.
224    pub fn is_empty(&self) -> bool {
225        self.biases.is_empty()
226    }
227
228    /// Access the bias array (for compiled kernel closure capture).
229    pub fn biases(&self) -> &[f64] {
230        &self.biases
231    }
232
233    /// Access the primary outcome array.
234    pub fn primaries(&self) -> &[u64] {
235        &self.primaries
236    }
237
238    /// Access the alias outcome array.
239    pub fn aliases(&self) -> &[u64] {
240        &self.aliases
241    }
242}
243
244// -----------------------------------------------------------------
245// Polydat node wrapping the alias table
246// -----------------------------------------------------------------
247//
248// `AliasSample` flows through the `Const<Vec<f64>>` workload-list
249// combinator (weights) and the `#[poly_const]` setup pattern
250// (cached `AliasTableU64`). The node has no u64 kit
251// (`Const<Vec<_>>`); it lowers through its slot kit, called from
252// native code.
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` has no named JitOp; it runs through its slot
363    // kit on every engine. The typed-eval path above covers
364    // correctness.
365
366    #[test]
367    fn single_outcome() {
368        let table = AliasTableU64::from_weights(&[1.0]);
369        for i in 0..1000 {
370            assert_eq!(table.sample(i), 0);
371        }
372    }
373
374    #[test]
375    fn two_outcomes_50_50() {
376        use xxhash_rust::xxh3::xxh3_64;
377
378        let table = AliasTableU64::from_weights(&[1.0, 1.0]);
379        let mut counts = [0u64; 2];
380        let n = 100_000u64;
381        for i in 0..n {
382            let hashed = xxh3_64(&i.to_le_bytes());
383            counts[table.sample(hashed) as usize] += 1;
384        }
385        let ratio = counts[0] as f64 / n as f64;
386        assert!(
387            (0.40..0.60).contains(&ratio),
388            "expected ~50/50, got ratio {ratio}"
389        );
390    }
391}