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