use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use uuid::Uuid;
use crate::error::ProxyError;
use crate::error::ProxyResult;
use crate::strategy::{BayesianObserver, ProxyCandidate, RotationStrategy, healthy_candidates};
use crate::types::{ProxyCapabilities, TrustTier};
pub const DEFAULT_PRIOR_ALPHA: u64 = 1;
pub const DEFAULT_PRIOR_BETA: u64 = 1;
pub const DEFAULT_DECAY_INTERVAL: Duration = Duration::from_mins(5);
pub const DEFAULT_DECAY_FACTOR: f64 = 0.95;
pub const PRIOR_BIAS_MIN_OBSERVATIONS: u64 = 4;
#[derive(Debug)]
struct ProxyBeta {
successes: AtomicU64,
failures: AtomicU64,
last_decay_ms: AtomicU64,
}
impl ProxyBeta {
const fn new(now_ms: u64) -> Self {
Self {
successes: AtomicU64::new(0),
failures: AtomicU64::new(0),
last_decay_ms: AtomicU64::new(now_ms),
}
}
#[inline]
fn read(&self) -> (u64, u64) {
let s = self.successes.load(Ordering::Relaxed);
let f = self.failures.load(Ordering::Relaxed);
(
DEFAULT_PRIOR_ALPHA.saturating_add(s),
DEFAULT_PRIOR_BETA.saturating_add(f),
)
}
fn record(&self, success: bool) {
if success {
self.successes.fetch_add(1, Ordering::AcqRel);
} else {
self.failures.fetch_add(1, Ordering::AcqRel);
}
}
fn apply_decay(&self, now_ms: u64, decay_factor: f64) -> bool {
let mut last = self.last_decay_ms.load(Ordering::Acquire);
loop {
if now_ms <= last {
return false;
}
match self.last_decay_ms.compare_exchange(
last,
now_ms,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => {
self.decay_counters(decay_factor);
return true;
}
Err(observed) => last = observed,
}
}
}
fn decay_counters(&self, decay_factor: f64) {
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)] fn scale(counter: &AtomicU64, factor: f64) {
let v = counter.load(Ordering::Relaxed);
#[allow(clippy::cast_precision_loss)] let vf = v as f64;
let scaled = (vf * factor).floor();
let next = if scaled < 0.0 {
0_u64
} else if scaled > (u64::MAX as f64) {
u64::MAX
} else {
scaled as u64
};
counter.store(next, Ordering::Release);
}
scale(&self.successes, decay_factor);
scale(&self.failures, decay_factor);
}
}
#[derive(Debug, Clone)]
pub struct Xorshift64 {
state: u64,
}
impl Xorshift64 {
#[must_use]
pub const fn seeded(seed: u64) -> Self {
let state = if seed == 0 {
0x9E37_79B9_7F4A_7C15_u64
} else {
seed
};
Self { state }
}
#[inline]
pub const fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.state = x;
x
}
#[inline]
pub fn next_unit_f64(&mut self) -> f64 {
let raw = self.next_u64() >> 11;
#[allow(clippy::cast_precision_loss)] let value = (raw as f64) / (1_u64 << 53) as f64;
value.clamp(0.0, 1.0_f64.next_down())
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)] fn sample_beta(rng: &mut Xorshift64, alpha: u64, beta: u64) -> f64 {
let a = alpha.max(1) as f64;
let b = beta.max(1) as f64;
if a <= 1.0 && b <= 1.0 {
let u = rng.next_unit_f64();
let v = rng.next_unit_f64();
let ua = u.powf(1.0 / a);
let vb = v.powf(1.0 / b);
return ua / (ua + vb);
}
let ga = sample_gamma(rng, a);
let gb = sample_gamma(rng, b);
let denom = ga + gb;
if denom <= 0.0 || !denom.is_finite() {
return rng.next_unit_f64();
}
(ga / denom).clamp(0.0, 1.0)
}
fn sample_gamma(rng: &mut Xorshift64, alpha: f64) -> f64 {
debug_assert!(alpha >= 1.0);
let d_val = alpha - 1.0 / 3.0;
let c_val = 1.0 / (9.0 * d_val).sqrt();
loop {
let (x, v) = marsaglia_tsang_step(rng, c_val);
if v <= 0.0 {
continue;
}
let x_sq = x * x;
let u = rng.next_unit_f64();
if u < 1.0_f64.mul_add(-(0.0331 * x_sq * x_sq), 1.0) {
return d_val * v;
}
if u.ln() < 0.5_f64.mul_add(x_sq, d_val * (1.0 - v + v.ln())) {
return d_val * v;
}
}
}
fn marsaglia_tsang_step(rng: &mut Xorshift64, c: f64) -> (f64, f64) {
loop {
let x = sample_standard_normal(rng);
let v = c.mul_add(x, 1.0).powi(3);
if v > 0.0 {
return (x, v);
}
}
}
fn sample_standard_normal(rng: &mut Xorshift64) -> f64 {
loop {
let u1 = rng.next_unit_f64();
let u2 = rng.next_unit_f64();
if u1 > 0.0 {
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * std::f64::consts::PI * u2;
return r * theta.cos();
}
}
}
fn beta_bias_for_tier(tier: TrustTier) -> f64 {
let rank = f64::from(tier.rank().max(1));
let raw = 4.0 / rank;
raw.clamp(0.25, 4.0)
}
fn vendor_beta_shift(
caps: &ProxyCapabilities,
target_vendor: Option<crate::types::VendorId>,
) -> f64 {
let Some(vendor) = target_vendor else {
return 1.0;
};
caps.target_compatibility
.get(vendor)
.map_or(1.0, beta_bias_for_tier)
}
#[derive(Debug)]
pub struct ThompsonStrategy {
betas: Mutex<HashMap<Uuid, Arc<ProxyBeta>>>,
rng: Mutex<Xorshift64>,
decay_factor: f64,
decay_interval: Duration,
target_vendor: Option<crate::types::VendorId>,
}
impl Default for ThompsonStrategy {
fn default() -> Self {
Self::with_rng_seed(0x9E37_79B9_7F4A_7C15)
}
}
#[allow(
clippy::expect_used,
clippy::panic,
reason = "lock poisoning implies a panic in the lock holder; the task is already torn down"
)]
impl ThompsonStrategy {
#[must_use]
pub fn with_rng_seed(seed: u64) -> Self {
Self {
betas: Mutex::new(HashMap::new()),
rng: Mutex::new(Xorshift64::seeded(seed)),
decay_factor: DEFAULT_DECAY_FACTOR,
decay_interval: DEFAULT_DECAY_INTERVAL,
target_vendor: None,
}
}
#[must_use]
pub fn with_decay(decay_interval: Duration, decay_factor: f64) -> Self {
let mut s = Self::with_rng_seed(0x9E37_79B9_7F4A_7C15);
s.decay_interval = decay_interval;
s.decay_factor = decay_factor.clamp(0.0, 1.0);
s
}
#[must_use]
pub fn with_decay_and_target(
decay_interval: Duration,
decay_factor: f64,
target_vendor: crate::types::VendorId,
) -> Self {
let mut s = Self::with_decay(decay_interval, decay_factor);
s.target_vendor = Some(target_vendor);
s
}
fn get_or_insert(&self, id: Uuid) -> Arc<ProxyBeta> {
{
let map = self
.betas
.lock()
.expect("ThompsonStrategy betas lock poisoned");
if let Some(b) = map.get(&id) {
return Arc::clone(b);
}
}
let mut map = self
.betas
.lock()
.expect("ThompsonStrategy betas lock poisoned");
if let Some(b) = map.get(&id) {
return Arc::clone(b);
}
let beta = Arc::new(ProxyBeta::new(now_ms()));
map.insert(id, Arc::clone(&beta));
beta
}
#[must_use]
pub fn counts_for(&self, id: Uuid) -> (u64, u64) {
self.betas
.lock()
.expect("ThompsonStrategy betas lock poisoned")
.get(&id)
.map_or((0, 0), |b| {
(
b.successes.load(Ordering::Relaxed),
b.failures.load(Ordering::Relaxed),
)
})
}
pub fn apply_decay(&self) {
let now = now_ms();
let factor = self.decay_factor;
let map = self
.betas
.lock()
.expect("ThompsonStrategy betas lock poisoned");
for beta in map.values() {
beta.apply_decay(now, factor);
}
}
}
#[async_trait]
#[allow(
clippy::expect_used,
clippy::panic,
reason = "lock poisoning implies a panic in the lock holder; the task is already torn down"
)]
impl RotationStrategy for ThompsonStrategy {
async fn select<'a>(
&self,
candidates: &'a [ProxyCandidate],
) -> ProxyResult<&'a ProxyCandidate> {
let healthy = healthy_candidates(candidates);
if healthy.is_empty() {
return Err(ProxyError::AllProxiesUnhealthy);
}
let mut rng = self.rng.lock().expect("ThompsonStrategy rng lock poisoned");
let target_vendor = self.target_vendor;
let mut best_idx = 0_usize;
let mut best_score = f64::NEG_INFINITY;
for (i, c) in healthy.iter().enumerate() {
let beta = self.get_or_insert(c.id);
let (alpha, beta_p) = beta.read();
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let (alpha, beta_p) = if alpha + beta_p <= PRIOR_BIAS_MIN_OBSERVATIONS {
let shift = vendor_beta_shift(&c.capabilities, target_vendor);
if shift > 1.0 {
(alpha, (u64_to_f64_loose(beta_p) * shift) as u64)
} else if shift < 1.0 {
((u64_to_f64_loose(alpha) * shift) as u64, beta_p)
} else {
(alpha, beta_p)
}
} else {
(alpha, beta_p)
};
let score = sample_beta(&mut rng, alpha, beta_p);
if score > best_score {
best_score = score;
best_idx = i;
}
}
drop(rng);
healthy
.get(best_idx)
.copied()
.ok_or(ProxyError::AllProxiesUnhealthy)
}
}
impl BayesianObserver for ThompsonStrategy {
fn observe(&self, proxy_id: Uuid, success: bool) {
#[allow(clippy::cast_possible_truncation)]
let interval_ms = self.decay_interval.as_millis() as u64;
let now = now_ms();
let beta = self.get_or_insert(proxy_id);
if now.saturating_sub(beta.last_decay_ms.load(Ordering::Acquire)) >= interval_ms {
beta.apply_decay(now, self.decay_factor);
}
beta.record(success);
}
}
#[inline]
#[allow(clippy::cast_precision_loss, clippy::cast_sign_loss)]
const fn u64_to_f64_loose(value: u64) -> f64 {
value as f64
}
#[inline]
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::strategy::tests::candidate;
use crate::types::{ProxyCapabilities, TargetVendorCompatibility, TrustTier, VendorId};
fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[tokio::test]
async fn synthetic_poisoned_pool_concentrates_traffic_on_alive_proxies() {
let strategy = ThompsonStrategy::with_rng_seed(0x1234_5678);
let mut candidates = Vec::with_capacity(100);
for i in 0..100_u128 {
let cap = if i < 90 {
ProxyCapabilities {
target_compatibility: TargetVendorCompatibility::default()
.set(VendorId::Akamai, TrustTier::Preferred),
..Default::default()
}
} else {
ProxyCapabilities::default()
};
candidates.push(ProxyCandidate {
id: Uuid::from_u128(i + 1),
weight: 1,
metrics: Arc::new(crate::types::ProxyMetrics::default()),
healthy: i < 90,
capabilities: cap,
});
}
for i in 0..90_u128 {
for _ in 0..5 {
strategy.observe(Uuid::from_u128(i + 1), true);
}
}
for i in 90..100_u128 {
for _ in 0..5 {
strategy.observe(Uuid::from_u128(i + 1), false);
}
}
let mut alive_hits = 0_u64;
let mut dead_hits = 0_u64;
for _ in 0..1_000 {
let chosen = strategy.select(&candidates).await.unwrap();
if chosen.id.as_u128() <= 90 {
alive_hits += 1;
} else {
dead_hits += 1;
}
}
let total = alive_hits + dead_hits;
#[allow(clippy::cast_precision_loss)] let alive_share = (alive_hits as f64) / (total as f64);
assert!(
alive_share > 0.9,
"alive proxies should receive >90% of traffic, got {alive_share:.3} (alive={alive_hits}, dead={dead_hits})"
);
assert!(
dead_hits < 100,
"dead proxies should be probed occasionally but <10% of traffic (got {dead_hits})"
);
}
#[tokio::test]
async fn decay_returns_proxy_to_neutral_over_time() {
let strategy = ThompsonStrategy::with_decay(Duration::from_millis(100), 0.5);
let a = Uuid::from_u128(0xA);
let b = Uuid::from_u128(0xB);
for _ in 0..100 {
strategy.observe(a, false);
strategy.observe(b, true);
}
let (_a_succ, a_fail) = strategy.counts_for(a);
let (b_succ, _b_fail) = strategy.counts_for(b);
assert!(a_fail >= 1, "proxy A failures should be recorded");
assert!(b_succ >= 1, "proxy B successes should be recorded");
tokio::time::sleep(Duration::from_millis(150)).await;
strategy.apply_decay();
let (_a_succ2, a_fail2) = strategy.counts_for(a);
let (b_succ2, _) = strategy.counts_for(b);
assert!(
a_fail2 < a_fail,
"proxy A failures should decay (was {a_fail}, now {a_fail2})"
);
assert!(
b_succ2 < b_succ,
"proxy B successes should decay (was {b_succ}, now {b_succ2})"
);
}
#[tokio::test]
async fn seeded_rng_produces_deterministic_winner() {
let s1 = ThompsonStrategy::with_rng_seed(0x00C0_FFEE);
let s2 = ThompsonStrategy::with_rng_seed(0x00C0_FFEE);
let candidates = vec![
candidate(1, true, 1, 0),
candidate(2, true, 1, 0),
candidate(3, true, 1, 0),
];
for i in 1..=3_u128 {
for _ in 0..5 {
s1.observe(Uuid::from_u128(i), true);
s2.observe(Uuid::from_u128(i), true);
}
}
let mut winners = Vec::new();
for _ in 0..50 {
let w1 = s1.select(&candidates).await.unwrap().id;
let w2 = s2.select(&candidates).await.unwrap().id;
assert_eq!(
w1, w2,
"same seed + same observations must produce same winner"
);
winners.push(w1);
}
let unique: std::collections::HashSet<_> = winners.iter().collect();
assert!(
unique.len() >= 2,
"expected at least two distinct winners over 50 draws, got {}",
unique.len()
);
}
#[tokio::test]
async fn preferred_vendor_bias_pulls_traffic_early() {
let strategy =
ThompsonStrategy::with_decay_and_target(Duration::from_hours(1), 1.0, VendorId::Akamai);
let preferred_caps = ProxyCapabilities {
target_compatibility: TargetVendorCompatibility::default()
.set(VendorId::Akamai, TrustTier::Preferred),
..Default::default()
};
let blocked_caps = ProxyCapabilities {
target_compatibility: TargetVendorCompatibility::default()
.set(VendorId::Akamai, TrustTier::Blocked),
..Default::default()
};
let preferred_id = Uuid::from_u128(0x1);
let blocked_id = Uuid::from_u128(0x2);
let candidates = vec![
ProxyCandidate {
id: preferred_id,
weight: 1,
metrics: Arc::new(crate::types::ProxyMetrics::default()),
healthy: true,
capabilities: preferred_caps,
},
ProxyCandidate {
id: blocked_id,
weight: 1,
metrics: Arc::new(crate::types::ProxyMetrics::default()),
healthy: true,
capabilities: blocked_caps,
},
];
let mut preferred_wins = 0_u64;
let mut blocked_wins = 0_u64;
for _ in 0..200 {
let chosen = strategy.select(&candidates).await.unwrap();
if chosen.id == preferred_id {
preferred_wins += 1;
} else {
blocked_wins += 1;
}
}
assert!(
preferred_wins > blocked_wins,
"preferred proxy should win more often (got preferred={preferred_wins}, blocked={blocked_wins})"
);
}
#[test]
fn sample_beta_stays_in_unit_interval() {
let mut rng = Xorshift64::seeded(0xABCD);
for _ in 0..1_000 {
let v = sample_beta(&mut rng, 3, 7);
assert!((0.0..=1.0).contains(&v), "got out-of-range sample {v}");
}
}
#[test]
fn apply_decay_is_idempotent_within_window() {
let beta = ProxyBeta::new(now_ms());
beta.successes.store(10, Ordering::Release);
beta.failures.store(4, Ordering::Release);
let now = now_ms() + 1_000;
assert!(beta.apply_decay(now, 0.5));
let s1 = beta.successes.load(Ordering::Relaxed);
assert!(!beta.apply_decay(now, 0.5));
let s2 = beta.successes.load(Ordering::Relaxed);
assert_eq!(s1, s2, "second apply_decay in same window must be a no-op");
}
#[test]
fn beta_sampler_tracks_analytic_mean() {
let mut rng = Xorshift64::seeded(0xBEEF);
let mut sum = 0.0_f64;
let n = 5_000;
for _ in 0..n {
sum += sample_beta(&mut rng, 80, 20);
}
let mean = sum / f64::from(n);
assert!(
approx_eq(mean, 0.80, 0.02),
"Beta(80, 20) sample mean should be ≈ 0.80 (got {mean:.4})"
);
}
#[tokio::test]
async fn acquire_hot_path_budget() {
let strategy = ThompsonStrategy::with_rng_seed(0xFEED_FACE);
let candidates: Vec<ProxyCandidate> = (0..10)
.map(|i| ProxyCandidate {
id: Uuid::from_u128(i + 1),
weight: 1,
metrics: Arc::new(crate::types::ProxyMetrics::default()),
healthy: true,
capabilities: ProxyCapabilities::default(),
})
.collect();
for _ in 0..10 {
let _ = strategy.select(&candidates).await.unwrap();
}
let start = std::time::Instant::now();
for _ in 0..1_000 {
let _ = strategy.select(&candidates).await.unwrap();
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(1),
"1 000 Thompson selections took {elapsed:?}; hot-path budget violated"
);
}
#[tokio::test]
async fn acquire_observe_hot_path_budget() {
let strategy = ThompsonStrategy::with_rng_seed(0xDEAD_BEEF);
let candidates: Vec<ProxyCandidate> = (0..10)
.map(|i| ProxyCandidate {
id: Uuid::from_u128(i + 1),
weight: 1,
metrics: Arc::new(crate::types::ProxyMetrics::default()),
healthy: true,
capabilities: ProxyCapabilities::default(),
})
.collect();
for _ in 0..10 {
let _ = strategy.select(&candidates).await.unwrap();
}
let start = std::time::Instant::now();
for i in 0..1_000_u64 {
let chosen = strategy.select(&candidates).await.unwrap();
strategy.observe(chosen.id, i % 3 != 0);
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(1),
"1 000 acquire+observe round-trips took {elapsed:?}; hot-path budget violated"
);
}
#[tokio::test]
async fn all_unhealthy_returns_error() {
let strategy = ThompsonStrategy::default();
let candidates = vec![candidate(1, false, 1, 0), candidate(2, false, 1, 0)];
assert!(matches!(
strategy.select(&candidates).await,
Err(ProxyError::AllProxiesUnhealthy)
));
}
#[tokio::test]
async fn apply_decay_is_thread_safe() {
let strategy = Arc::new(ThompsonStrategy::with_decay(Duration::from_millis(0), 0.99));
for i in 0..50_u128 {
strategy.observe(Uuid::from_u128(i + 1), true);
}
let s = Arc::clone(&strategy);
let h = std::thread::spawn(move || {
for _ in 0..10 {
s.apply_decay();
}
});
for _ in 0..10 {
strategy.apply_decay();
}
assert!(h.join().is_ok(), "apply_decay worker must not panic");
}
}