1use crate::bandit::stats::ArmStats;
24use crate::bandit::{
25 Bandit, checked_finite_add, checked_increment, validate_arm, validate_reward_01,
26 validate_sample_count,
27};
28use crate::error::RillError;
29use rand::Rng;
30
31#[derive(Debug, Clone, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub struct ThompsonConfig {
47 pub alpha_prior: f64,
52
53 pub beta_prior: f64,
58}
59
60impl Default for ThompsonConfig {
61 fn default() -> Self {
62 Self {
63 alpha_prior: 1.0,
64 beta_prior: 1.0,
65 }
66 }
67}
68
69impl ThompsonConfig {
70 pub fn validate(&self) -> Result<(), RillError> {
72 if !self.alpha_prior.is_finite() || self.alpha_prior <= 0.0 {
73 return Err(RillError::InvalidParameter {
74 name: "alpha_prior",
75 value: self.alpha_prior,
76 });
77 }
78 if !self.beta_prior.is_finite() || self.beta_prior <= 0.0 {
79 return Err(RillError::InvalidParameter {
80 name: "beta_prior",
81 value: self.beta_prior,
82 });
83 }
84 Ok(())
85 }
86}
87
88#[derive(Debug, Clone)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize))]
115pub struct ThompsonSampling {
116 arm_count: usize,
117 config: ThompsonConfig,
118 alphas: Vec<f64>,
120 betas: Vec<f64>,
122 pulls: Vec<u64>,
124 total_rewards: Vec<f64>,
126 samples_seen: u64,
128}
129
130impl ThompsonSampling {
131 pub fn new(arm_count: usize, config: ThompsonConfig) -> Result<Self, RillError> {
138 if arm_count == 0 {
139 return Err(RillError::InvalidArmCount(arm_count));
140 }
141 config.validate()?;
142
143 let alpha_prior = config.alpha_prior;
144 let beta_prior = config.beta_prior;
145 Ok(Self {
146 arm_count,
147 config,
148 alphas: vec![alpha_prior; arm_count],
149 betas: vec![beta_prior; arm_count],
150 pulls: vec![0; arm_count],
151 total_rewards: vec![0.0; arm_count],
152 samples_seen: 0,
153 })
154 }
155
156 pub fn alphas(&self) -> &[f64] {
158 &self.alphas
159 }
160
161 pub fn betas(&self) -> &[f64] {
163 &self.betas
164 }
165
166 pub fn pulls(&self) -> &[u64] {
168 &self.pulls
169 }
170
171 pub fn validate(&self) -> Result<(), RillError> {
175 if self.arm_count == 0 {
176 return Err(RillError::InvalidArmCount(self.arm_count));
177 }
178 self.config.validate()?;
179 if self.alphas.len() != self.arm_count
180 || self.betas.len() != self.arm_count
181 || self.pulls.len() != self.arm_count
182 || self.total_rewards.len() != self.arm_count
183 {
184 return Err(RillError::InvalidState(
185 "arm_count does not match per-arm state lengths".to_owned(),
186 ));
187 }
188 validate_sample_count(&self.pulls, self.samples_seen)?;
189
190 for arm in 0..self.arm_count {
191 let pulls = self.pulls[arm] as f64;
192 let total = self.total_rewards[arm];
193 let alpha = self.alphas[arm];
194 let beta = self.betas[arm];
195 if !total.is_finite() || total < 0.0 || total > pulls {
196 return Err(RillError::InvalidState(format!(
197 "total reward for arm {arm} is inconsistent with [0, 1] rewards"
198 )));
199 }
200 let expected_alpha = self.config.alpha_prior + total;
201 let expected_beta = self.config.beta_prior + pulls - total;
202 let alpha_tolerance = 1e-9 * expected_alpha.abs().max(1.0);
203 let beta_tolerance = 1e-9 * expected_beta.abs().max(1.0);
204 if !alpha.is_finite()
205 || !beta.is_finite()
206 || (alpha - expected_alpha).abs() > alpha_tolerance
207 || (beta - expected_beta).abs() > beta_tolerance
208 {
209 return Err(RillError::InvalidState(format!(
210 "posterior parameters for arm {arm} are inconsistent with observations"
211 )));
212 }
213 }
214 Ok(())
215 }
216
217 fn sample_beta(rng: &mut impl Rng, alpha: f64, beta: f64) -> f64 {
222 let x = Self::sample_gamma(rng, alpha);
223 let y = Self::sample_gamma(rng, beta);
224 let denom = x + y;
226 if denom <= 0.0 {
227 0.5
229 } else {
230 x / denom
231 }
232 }
233
234 fn sample_gamma(rng: &mut impl Rng, shape: f64) -> f64 {
241 if shape < 1.0 {
242 let u: f64 = rng.gen_range(1e-10..1.0);
244 let g = Self::sample_gamma(rng, shape + 1.0);
245 return g * u.powf(1.0 / shape);
246 }
247
248 let d = shape - 1.0 / 3.0;
250 let c = 1.0 / (9.0 * d).sqrt();
251
252 loop {
253 let (x, _unused) = Self::box_muller(rng);
255 let v = (1.0 + c * x).powi(3);
256 if v <= 0.0 {
257 continue;
258 }
259 let u: f64 = rng.gen_range(0.0..1.0);
260 if u < 1.0 - 0.0331 * x.powi(4) {
261 return d * v;
262 }
263 if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
264 return d * v;
265 }
266 }
267 }
268
269 fn box_muller(rng: &mut impl Rng) -> (f64, f64) {
272 let u1: f64 = rng.gen_range(1e-10..1.0);
273 let u2: f64 = rng.gen_range(0.0..1.0);
274 let mag = (-2.0 * u1.ln()).sqrt();
275 let z0 = mag * (2.0 * std::f64::consts::PI * u2).cos();
276 let z1 = mag * (2.0 * std::f64::consts::PI * u2).sin();
277 (z0, z1)
278 }
279}
280
281impl Bandit for ThompsonSampling {
282 fn arm_count(&self) -> usize {
283 self.arm_count
284 }
285
286 fn samples_seen(&self) -> u64 {
287 self.samples_seen
288 }
289
290 fn select(&self, rng: &mut impl Rng) -> Result<usize, RillError> {
291 let mut best_arm = 0usize;
292 let mut best_sample = f64::NEG_INFINITY;
293
294 for arm in 0..self.arm_count {
295 let sample = Self::sample_beta(rng, self.alphas[arm], self.betas[arm]);
296 if sample > best_sample {
297 best_sample = sample;
298 best_arm = arm;
299 }
300 }
301
302 Ok(best_arm)
303 }
304
305 fn update(&mut self, arm: usize, reward: f64) -> Result<(), RillError> {
306 validate_arm(self.arm_count, arm)?;
307 validate_reward_01(reward)?;
308
309 let next_alpha = checked_finite_add(self.alphas[arm], reward, "alpha")?;
314 let next_beta = checked_finite_add(self.betas[arm], 1.0 - reward, "beta")?;
315 let next_pulls = checked_increment(self.pulls[arm], "pulls")?;
316 let next_total = checked_finite_add(self.total_rewards[arm], reward, "total_rewards")?;
317 let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
318
319 self.alphas[arm] = next_alpha;
320 self.betas[arm] = next_beta;
321 self.pulls[arm] = next_pulls;
322 self.total_rewards[arm] = next_total;
323 self.samples_seen = next_samples;
324 Ok(())
325 }
326
327 fn reset(&mut self) {
328 for a in &mut self.alphas {
329 *a = self.config.alpha_prior;
330 }
331 for b in &mut self.betas {
332 *b = self.config.beta_prior;
333 }
334 for p in &mut self.pulls {
335 *p = 0;
336 }
337 for r in &mut self.total_rewards {
338 *r = 0.0;
339 }
340 self.samples_seen = 0;
341 }
342
343 fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
344 validate_arm(self.arm_count, arm)?;
345 ArmStats::new(self.pulls[arm], self.total_rewards[arm])
346 }
347}
348
349#[cfg(feature = "serde")]
350#[derive(serde::Deserialize)]
351struct ThompsonSamplingState {
352 arm_count: usize,
353 config: ThompsonConfig,
354 alphas: Vec<f64>,
355 betas: Vec<f64>,
356 pulls: Vec<u64>,
357 total_rewards: Vec<f64>,
358 samples_seen: u64,
359}
360
361#[cfg(feature = "serde")]
362impl<'de> serde::Deserialize<'de> for ThompsonSampling {
363 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
364 where
365 D: serde::Deserializer<'de>,
366 {
367 let state = ThompsonSamplingState::deserialize(deserializer)?;
368 let bandit = Self {
369 arm_count: state.arm_count,
370 config: state.config,
371 alphas: state.alphas,
372 betas: state.betas,
373 pulls: state.pulls,
374 total_rewards: state.total_rewards,
375 samples_seen: state.samples_seen,
376 };
377 bandit.validate().map_err(serde::de::Error::custom)?;
378 Ok(bandit)
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use rand::SeedableRng;
386 use rand_chacha::ChaCha8Rng;
387
388 fn make_bandit() -> ThompsonSampling {
389 ThompsonSampling::new(3, ThompsonConfig::default()).unwrap()
390 }
391
392 #[test]
393 fn rejects_zero_arm_count() {
394 let result = ThompsonSampling::new(0, ThompsonConfig::default());
395 assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
396 }
397
398 #[test]
399 fn rejects_invalid_priors() {
400 for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
401 let result = ThompsonSampling::new(
402 3,
403 ThompsonConfig {
404 alpha_prior: bad,
405 beta_prior: 1.0,
406 },
407 );
408 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
409
410 let result = ThompsonSampling::new(
411 3,
412 ThompsonConfig {
413 alpha_prior: 1.0,
414 beta_prior: bad,
415 },
416 );
417 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
418 }
419 }
420
421 #[test]
422 fn initial_state() {
423 let b = make_bandit();
424 assert_eq!(b.arm_count(), 3);
425 assert_eq!(b.samples_seen(), 0);
426 for &a in b.alphas() {
428 assert!((a - 1.0).abs() < 1e-12);
429 }
430 for &be in b.betas() {
431 assert!((be - 1.0).abs() < 1e-12);
432 }
433 }
434
435 #[test]
436 fn select_returns_valid_arm() {
437 let b = make_bandit();
438 let mut rng = ChaCha8Rng::seed_from_u64(42);
439 let arm = b.select(&mut rng).unwrap();
440 assert!(arm < 3);
441 }
442
443 #[test]
444 fn update_with_success_increases_alpha() {
445 let mut b = make_bandit();
446 b.update(0, 1.0).unwrap();
447 assert!((b.alphas()[0] - 2.0).abs() < 1e-12);
449 assert!((b.betas()[0] - 1.0).abs() < 1e-12);
450 }
451
452 #[test]
453 fn update_with_failure_increases_beta() {
454 let mut b = make_bandit();
455 b.update(0, 0.0).unwrap();
456 assert!((b.alphas()[0] - 1.0).abs() < 1e-12);
458 assert!((b.betas()[0] - 2.0).abs() < 1e-12);
459 }
460
461 #[test]
462 fn update_with_continuous_reward() {
463 let mut b = make_bandit();
464 b.update(0, 0.7).unwrap();
465 assert!((b.alphas()[0] - 1.7).abs() < 1e-12);
467 assert!((b.betas()[0] - 1.3).abs() < 1e-12);
468 }
469
470 #[test]
471 fn update_rejects_invalid_arm() {
472 let mut b = make_bandit();
473 assert!(b.update(3, 1.0).is_err());
474 }
475
476 #[test]
477 fn update_rejects_reward_out_of_range() {
478 let mut b = make_bandit();
479 assert!(b.update(0, 1.5).is_err());
480 assert!(b.update(0, -0.1).is_err());
481 assert!(b.update(0, f64::NAN).is_err());
482 }
483
484 #[test]
485 fn reset_clears_state() {
486 let mut b = make_bandit();
487 b.update(0, 1.0).unwrap();
488 b.update(1, 0.0).unwrap();
489 assert_eq!(b.samples_seen(), 2);
490
491 b.reset();
492 assert_eq!(b.samples_seen(), 0);
493 for &a in b.alphas() {
494 assert!((a - 1.0).abs() < 1e-12);
495 }
496 for &be in b.betas() {
497 assert!((be - 1.0).abs() < 1e-12);
498 }
499 for &p in b.pulls() {
500 assert_eq!(p, 0);
501 }
502 }
503
504 #[test]
505 fn arm_stats_after_updates() {
506 let mut b = make_bandit();
507 b.update(0, 1.0).unwrap();
508 b.update(0, 0.0).unwrap();
509 b.update(0, 1.0).unwrap();
510 let stats = b.arm_stats(0).unwrap();
511 assert_eq!(stats.pulls, 3);
512 assert!((stats.total_reward - 2.0).abs() < 1e-12);
513 }
514
515 #[test]
516 fn arm_stats_rejects_invalid_arm() {
517 let b = make_bandit();
518 assert!(b.arm_stats(5).is_err());
519 }
520
521 #[test]
522 fn finds_best_arm_in_simulation() {
523 let mut b = make_bandit();
524 let mut rng = ChaCha8Rng::seed_from_u64(42);
525
526 for _ in 0..1000 {
529 let arm = b.select(&mut rng).unwrap();
530 let p = match arm {
531 0 => 0.8,
532 1 => 0.2,
533 _ => 0.5,
534 };
535 let reward = if rng.gen_range(0.0..1.0) < p {
536 1.0
537 } else {
538 0.0
539 };
540 b.update(arm, reward).unwrap();
541 }
542
543 let stats0 = b.arm_stats(0).unwrap();
545 let stats1 = b.arm_stats(1).unwrap();
546 let stats2 = b.arm_stats(2).unwrap();
547 assert!(stats0.pulls > stats1.pulls);
548 assert!(stats0.pulls > stats2.pulls);
549 assert!(stats0.mean_reward > 0.6);
551 }
552
553 #[test]
554 fn sample_beta_returns_value_in_unit_interval() {
555 let mut rng = ChaCha8Rng::seed_from_u64(99);
556 for _ in 0..1000 {
557 let v = ThompsonSampling::sample_beta(&mut rng, 2.0, 5.0);
558 assert!((0.0..=1.0).contains(&v), "Beta sample {v} out of [0, 1]");
559 }
560 }
561
562 #[test]
563 fn sample_gamma_returns_positive_value() {
564 let mut rng = ChaCha8Rng::seed_from_u64(7);
565 for shape in &[0.5, 1.0, 2.0, 5.0, 10.0] {
566 for _ in 0..100 {
567 let v = ThompsonSampling::sample_gamma(&mut rng, *shape);
568 assert!(v > 0.0, "Gamma sample {v} not positive for shape {shape}");
569 }
570 }
571 }
572
573 #[test]
574 fn sample_gamma_mean_converges() {
575 let mut rng = ChaCha8Rng::seed_from_u64(42);
577 let shape = 5.0;
578 let n = 10000;
579 let mut sum = 0.0;
580 for _ in 0..n {
581 sum += ThompsonSampling::sample_gamma(&mut rng, shape);
582 }
583 let mean = sum / n as f64;
584 assert!(
586 (mean - shape).abs() / shape < 0.1,
587 "Gamma mean {mean} too far from {shape}"
588 );
589 }
590
591 #[test]
592 fn sample_beta_mean_converges() {
593 let mut rng = ChaCha8Rng::seed_from_u64(42);
595 let alpha = 2.0;
596 let beta = 5.0;
597 let n = 10000;
598 let mut sum = 0.0;
599 for _ in 0..n {
600 sum += ThompsonSampling::sample_beta(&mut rng, alpha, beta);
601 }
602 let mean = sum / n as f64;
603 let expected = alpha / (alpha + beta);
604 assert!(
606 (mean - expected).abs() / expected < 0.1,
607 "Beta mean {mean} too far from {expected}"
608 );
609 }
610
611 #[cfg(feature = "serde")]
612 #[test]
613 fn serde_roundtrip() {
614 let mut b = ThompsonSampling::new(
615 3,
616 ThompsonConfig {
617 alpha_prior: 2.0,
618 beta_prior: 3.0,
619 },
620 )
621 .unwrap();
622 b.update(0, 1.0).unwrap();
623 b.update(1, 0.0).unwrap();
624
625 let json = serde_json::to_string(&b).unwrap();
626 let restored: ThompsonSampling = serde_json::from_str(&json).unwrap();
627 assert_eq!(restored.arm_count(), b.arm_count());
628 assert_eq!(restored.samples_seen(), b.samples_seen());
629 assert_eq!(restored.alphas(), b.alphas());
630 assert_eq!(restored.betas(), b.betas());
631 }
632
633 #[cfg(feature = "serde")]
634 #[test]
635 fn serde_rejects_malformed_state() {
636 let json = r#"{
637 "arm_count": 2,
638 "config": {"alpha_prior": 1.0, "beta_prior": 1.0},
639 "alphas": [2.0],
640 "betas": [1.0],
641 "pulls": [1],
642 "total_rewards": [1.0],
643 "samples_seen": 1
644 }"#;
645 assert!(serde_json::from_str::<ThompsonSampling>(json).is_err());
646 }
647}