use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
pub const RESAMPLE_INTERVAL: u64 = 10_000;
#[derive(Debug)]
pub struct BetaArm {
pub alpha: AtomicU64,
pub beta: AtomicU64,
pub arm_ratio: f64,
}
impl BetaArm {
#[must_use]
pub fn new(arm_ratio: f64) -> Self {
Self {
alpha: AtomicU64::new(1),
beta: AtomicU64::new(1),
arm_ratio,
}
}
}
#[derive(Debug)]
pub struct ThompsonPartitioner {
arms: Vec<BetaArm>,
current_arm: AtomicUsize,
access_count: AtomicU64,
}
impl Default for ThompsonPartitioner {
fn default() -> Self {
Self::new()
}
}
impl ThompsonPartitioner {
#[must_use]
pub fn new() -> Self {
let arms: Vec<BetaArm> = (1..=9).map(|i| BetaArm::new(f64::from(i) / 10.0)).collect();
Self {
arms,
current_arm: AtomicUsize::new(4),
access_count: AtomicU64::new(0),
}
}
#[must_use]
pub fn current_hot_ratio(&self) -> f64 {
let idx = self.current_arm.load(Ordering::Relaxed);
self.arms[idx].arm_ratio
}
pub fn record_outcome(&self, hot_hit: bool) {
let idx = self.current_arm.load(Ordering::Relaxed);
let arm = &self.arms[idx];
if hot_hit {
arm.alpha.fetch_add(1, Ordering::Relaxed);
} else {
arm.beta.fetch_add(1, Ordering::Relaxed);
}
}
pub fn tick(&self) -> bool {
let prev = self.access_count.fetch_add(1, Ordering::Relaxed);
let count = prev.wrapping_add(1);
if count % RESAMPLE_INTERVAL == 0 {
self.resample();
true
} else {
false
}
}
pub fn resample(&self) {
let seed = self
.access_count
.load(Ordering::Relaxed)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add(0xD1B5_4A32_D192_ED03);
let mut rng = SplitMix64::new(seed);
let mut best_idx = 0usize;
let mut best_sample = f64::NEG_INFINITY;
for (idx, arm) in self.arms.iter().enumerate() {
let a = arm.alpha.load(Ordering::Relaxed) as f64;
let b = arm.beta.load(Ordering::Relaxed) as f64;
let sample = sample_beta(&mut rng, a, b);
if sample > best_sample {
best_sample = sample;
best_idx = idx;
}
}
self.current_arm.store(best_idx, Ordering::Relaxed);
}
#[must_use]
pub fn arm_count(&self) -> usize {
self.arms.len()
}
#[must_use]
pub fn arms(&self) -> &[BetaArm] {
&self.arms
}
#[must_use]
pub fn current_arm_index(&self) -> usize {
self.current_arm.load(Ordering::Relaxed)
}
}
#[derive(Debug, Clone)]
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
let state = if seed == 0 {
0xDEAD_BEEF_DEAD_BEEF
} else {
seed
};
Self { state }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_f64_open(&mut self) -> f64 {
let bits = self.next_u64() >> 11; let x = (bits as f64) * (1.0f64 / ((1u64 << 53) as f64));
if x <= 0.0 { f64::MIN_POSITIVE } else { x }
}
fn next_normal(&mut self) -> f64 {
loop {
let u1 = 2.0_f64.mul_add(self.next_f64_open(), -1.0);
let u2 = 2.0_f64.mul_add(self.next_f64_open(), -1.0);
let s = u1.mul_add(u1, u2 * u2);
if s > 0.0 && s < 1.0 {
let factor = (-2.0 * s.ln() / s).sqrt();
return u1 * factor;
}
}
}
}
#[allow(clippy::cast_precision_loss)]
fn sample_gamma(rng: &mut SplitMix64, shape: f64) -> f64 {
debug_assert!(shape > 0.0);
if shape < 1.0 {
let g = sample_gamma(rng, shape + 1.0);
let u = rng.next_f64_open();
return g * u.powf(1.0 / shape);
}
let d = shape - 1.0 / 3.0;
let c = 1.0 / (9.0 * d).sqrt();
loop {
let x = rng.next_normal();
let v_base = 1.0 + c * x;
if v_base <= 0.0 {
continue;
}
let v = v_base * v_base * v_base;
let u = rng.next_f64_open();
let x2 = x * x;
if u < (0.0331 * x2).mul_add(-x2, 1.0) {
return d * v;
}
if u.ln() < 0.5_f64.mul_add(x2, d * (1.0 - v + v.ln())) {
return d * v;
}
}
}
fn sample_beta(rng: &mut SplitMix64, alpha: f64, beta: f64) -> f64 {
let x = sample_gamma(rng, alpha);
let y = sample_gamma(rng, beta);
let denom = x + y;
if denom <= 0.0 {
return rng.next_f64_open();
}
x / denom
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn partitioner_has_nine_arms_with_expected_ratios() {
let p = ThompsonPartitioner::new();
assert_eq!(p.arm_count(), 9);
for (i, arm) in p.arms().iter().enumerate() {
let expected = f64::from(u32::try_from(i + 1).unwrap()) / 10.0;
assert!(
(arm.arm_ratio - expected).abs() < 1e-9,
"arm {i} ratio = {} expected {expected}",
arm.arm_ratio
);
assert_eq!(arm.alpha.load(Ordering::Relaxed), 1);
assert_eq!(arm.beta.load(Ordering::Relaxed), 1);
}
assert!((p.current_hot_ratio() - 0.5).abs() < 1e-9);
assert_eq!(p.current_arm_index(), 4);
}
#[test]
fn heavily_rewarded_arm_is_selected_after_resample() {
let p = ThompsonPartitioner::new();
p.current_arm.store(4, Ordering::Relaxed);
for _ in 0..1_000 {
p.record_outcome(true);
}
assert_eq!(p.arms()[4].alpha.load(Ordering::Relaxed), 1_001);
for (i, arm) in p.arms().iter().enumerate() {
if i == 4 {
continue;
}
assert_eq!(arm.alpha.load(Ordering::Relaxed), 1);
assert_eq!(arm.beta.load(Ordering::Relaxed), 1);
}
p.resample();
assert_eq!(p.current_arm_index(), 4);
assert!((p.current_hot_ratio() - 0.5).abs() < 1e-9);
}
#[test]
fn uniform_priors_produce_valid_arm_index() {
let p = ThompsonPartitioner::new();
p.resample();
let idx = p.current_arm_index();
assert!(idx < p.arm_count(), "idx {idx} out of range");
let ratio = p.current_hot_ratio();
assert!(
(0.1..=0.9).contains(&ratio),
"ratio {ratio} not in [0.1, 0.9]"
);
}
#[test]
fn tick_resamples_every_interval() {
let p = ThompsonPartitioner::new();
p.current_arm.store(8, Ordering::Relaxed);
for _ in 0..5_000 {
p.record_outcome(true);
}
for _ in 0..(RESAMPLE_INTERVAL as usize - 1) {
assert!(!p.tick());
}
assert!(p.tick());
assert_eq!(p.current_arm_index(), 8);
}
#[test]
fn gamma_samples_are_positive_and_finite() {
let mut rng = SplitMix64::new(42);
for shape in [0.25_f64, 0.5, 1.0, 1.5, 5.0, 50.0] {
for _ in 0..32 {
let g = sample_gamma(&mut rng, shape);
assert!(g.is_finite() && g > 0.0, "gamma({shape}) = {g}");
}
}
}
#[test]
fn beta_samples_are_in_unit_interval() {
let mut rng = SplitMix64::new(12345);
for (a, b) in [(1.0_f64, 1.0), (2.0, 5.0), (100.0, 1.0), (1.0, 100.0)] {
for _ in 0..64 {
let s = sample_beta(&mut rng, a, b);
assert!(
s.is_finite() && (0.0..=1.0).contains(&s),
"beta({a},{b}) = {s}"
);
}
}
}
}