1use crate::bandit::stats::ArmStats;
17use crate::bandit::{
18 Bandit, checked_finite_add, checked_increment, validate_arm, validate_reward_finite,
19 validate_sample_count,
20};
21use crate::error::RillError;
22#[cfg(feature = "serde")]
23use crate::persistence::ValidateState;
24use rand::Rng;
25
26#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[non_exhaustive]
42pub struct EpsilonGreedyConfig {
43 pub epsilon: f64,
47
48 pub decay: f64,
53
54 pub min_epsilon: f64,
56}
57
58impl Default for EpsilonGreedyConfig {
59 fn default() -> Self {
60 Self {
61 epsilon: 0.1,
62 decay: 1.0,
63 min_epsilon: 0.01,
64 }
65 }
66}
67
68impl EpsilonGreedyConfig {
69 pub fn validate(&self) -> Result<(), RillError> {
71 if !(0.0..=1.0).contains(&self.epsilon) {
72 return Err(RillError::InvalidEpsilon(self.epsilon));
73 }
74 if !(0.0 < self.decay && self.decay <= 1.0) {
75 return Err(RillError::InvalidParameter {
76 name: "decay",
77 value: self.decay,
78 });
79 }
80 if !(0.0..=self.epsilon).contains(&self.min_epsilon) {
81 return Err(RillError::InvalidParameter {
82 name: "min_epsilon",
83 value: self.min_epsilon,
84 });
85 }
86 Ok(())
87 }
88}
89
90#[derive(Debug, Clone)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111pub struct EpsilonGreedy {
112 arm_count: usize,
113 config: EpsilonGreedyConfig,
114 pulls: Vec<u64>,
116 total_rewards: Vec<f64>,
118 samples_seen: u64,
120 current_epsilon: f64,
122}
123
124impl EpsilonGreedy {
125 pub fn new(arm_count: usize, config: EpsilonGreedyConfig) -> Result<Self, RillError> {
133 if arm_count == 0 {
134 return Err(RillError::InvalidArmCount(arm_count));
135 }
136 config.validate()?;
137
138 Ok(Self {
139 arm_count,
140 current_epsilon: config.epsilon,
141 config,
142 pulls: vec![0; arm_count],
143 total_rewards: vec![0.0; arm_count],
144 samples_seen: 0,
145 })
146 }
147
148 pub const fn current_epsilon(&self) -> f64 {
150 self.current_epsilon
151 }
152
153 pub fn pulls(&self) -> &[u64] {
155 &self.pulls
156 }
157
158 pub fn total_rewards(&self) -> &[f64] {
160 &self.total_rewards
161 }
162
163 pub fn validate(&self) -> Result<(), RillError> {
167 if self.arm_count == 0 {
168 return Err(RillError::InvalidArmCount(self.arm_count));
169 }
170 self.config.validate()?;
171 if self.pulls.len() != self.arm_count || self.total_rewards.len() != self.arm_count {
172 return Err(RillError::InvalidState(
173 "arm_count does not match per-arm state lengths".to_owned(),
174 ));
175 }
176 if self.total_rewards.iter().any(|value| !value.is_finite()) {
177 return Err(RillError::InvalidState(
178 "total_rewards must contain only finite values".to_owned(),
179 ));
180 }
181 validate_sample_count(&self.pulls, self.samples_seen)?;
182 if !self.current_epsilon.is_finite()
183 || self.current_epsilon < self.config.min_epsilon
184 || self.current_epsilon > self.config.epsilon
185 {
186 return Err(RillError::InvalidState(
187 "current_epsilon is outside the configured bounds".to_owned(),
188 ));
189 }
190 Ok(())
191 }
192
193 fn best_arm(&self) -> usize {
196 let mut best = 0usize;
197 let mut best_mean = f64::NEG_INFINITY;
198 for (i, &pulls) in self.pulls.iter().enumerate() {
199 let mean = if pulls > 0 {
200 self.total_rewards[i] / pulls as f64
201 } else {
202 f64::NEG_INFINITY
205 };
206 if mean > best_mean {
207 best_mean = mean;
208 best = i;
209 }
210 }
211 best
213 }
214}
215
216impl Bandit for EpsilonGreedy {
217 fn arm_count(&self) -> usize {
218 self.arm_count
219 }
220
221 fn samples_seen(&self) -> u64 {
222 self.samples_seen
223 }
224
225 fn select(&self, rng: &mut impl Rng) -> Result<usize, RillError> {
226 if self.samples_seen == 0 || self.pulls.iter().all(|&p| p == 0) {
228 let arm = rng.gen_range(0..self.arm_count);
229 return Ok(arm);
230 }
231
232 let r: f64 = rng.gen_range(0.0..1.0);
234 if r < self.current_epsilon {
235 let arm = rng.gen_range(0..self.arm_count);
237 Ok(arm)
238 } else {
239 Ok(self.best_arm())
241 }
242 }
243
244 fn update(&mut self, arm: usize, reward: f64) -> Result<(), RillError> {
245 validate_arm(self.arm_count, arm)?;
246 validate_reward_finite(reward)?;
247
248 let next_pulls = checked_increment(self.pulls[arm], "pulls")?;
251 let next_total = checked_finite_add(self.total_rewards[arm], reward, "total_rewards")?;
252 let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
253
254 let next_epsilon = if self.config.decay < 1.0 {
256 (self.current_epsilon * self.config.decay).max(self.config.min_epsilon)
257 } else {
258 self.current_epsilon
259 };
260
261 self.pulls[arm] = next_pulls;
262 self.total_rewards[arm] = next_total;
263 self.samples_seen = next_samples;
264 self.current_epsilon = next_epsilon;
265
266 Ok(())
267 }
268
269 fn reset(&mut self) {
270 for p in &mut self.pulls {
271 *p = 0;
272 }
273 for r in &mut self.total_rewards {
274 *r = 0.0;
275 }
276 self.samples_seen = 0;
277 self.current_epsilon = self.config.epsilon;
278 }
279
280 fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
281 validate_arm(self.arm_count, arm)?;
282 ArmStats::new(self.pulls[arm], self.total_rewards[arm])
283 }
284}
285
286#[cfg(feature = "serde")]
287#[derive(serde::Deserialize)]
288struct EpsilonGreedyState {
289 arm_count: usize,
290 config: EpsilonGreedyConfig,
291 pulls: Vec<u64>,
292 total_rewards: Vec<f64>,
293 samples_seen: u64,
294 current_epsilon: f64,
295}
296
297#[cfg(feature = "serde")]
298impl<'de> serde::Deserialize<'de> for EpsilonGreedy {
299 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
300 where
301 D: serde::Deserializer<'de>,
302 {
303 let state = EpsilonGreedyState::deserialize(deserializer)?;
304 let bandit = Self {
305 arm_count: state.arm_count,
306 config: state.config,
307 pulls: state.pulls,
308 total_rewards: state.total_rewards,
309 samples_seen: state.samples_seen,
310 current_epsilon: state.current_epsilon,
311 };
312 bandit.validate().map_err(serde::de::Error::custom)?;
313 Ok(bandit)
314 }
315}
316
317#[cfg(feature = "serde")]
318impl ValidateState for EpsilonGreedy {
319 fn validate_state(&self) -> Result<(), RillError> {
320 EpsilonGreedy::validate(self)
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327 use rand::SeedableRng;
328 use rand_chacha::ChaCha8Rng;
329
330 fn make_bandit(epsilon: f64) -> EpsilonGreedy {
331 EpsilonGreedy::new(
332 3,
333 EpsilonGreedyConfig {
334 epsilon,
335 decay: 1.0,
336 min_epsilon: 0.0,
337 },
338 )
339 .unwrap()
340 }
341
342 #[test]
343 fn rejects_zero_arm_count() {
344 let result = EpsilonGreedy::new(0, EpsilonGreedyConfig::default());
345 assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
346 }
347
348 #[test]
349 fn rejects_invalid_epsilon() {
350 let result = EpsilonGreedy::new(
351 3,
352 EpsilonGreedyConfig {
353 epsilon: 1.5,
354 decay: 1.0,
355 min_epsilon: 0.0,
356 },
357 );
358 assert!(matches!(result, Err(RillError::InvalidEpsilon(_))));
359 }
360
361 #[test]
362 fn rejects_invalid_decay() {
363 let result = EpsilonGreedy::new(
364 3,
365 EpsilonGreedyConfig {
366 epsilon: 0.1,
367 decay: 0.0,
368 min_epsilon: 0.0,
369 },
370 );
371 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
372 }
373
374 #[test]
375 fn rejects_invalid_min_epsilon() {
376 let result = EpsilonGreedy::new(
377 3,
378 EpsilonGreedyConfig {
379 epsilon: 0.1,
380 decay: 1.0,
381 min_epsilon: 0.5, },
383 );
384 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
385 }
386
387 #[test]
388 fn initial_state() {
389 let b = make_bandit(0.1);
390 assert_eq!(b.arm_count(), 3);
391 assert_eq!(b.samples_seen(), 0);
392 assert!((b.current_epsilon() - 0.1).abs() < 1e-12);
393 for &pulls in b.pulls() {
394 assert_eq!(pulls, 0);
395 }
396 }
397
398 #[test]
399 fn select_with_no_data_returns_valid_arm() {
400 let b = make_bandit(0.1);
401 let mut rng = ChaCha8Rng::seed_from_u64(42);
402 let arm = b.select(&mut rng).unwrap();
403 assert!(arm < 3);
404 }
405
406 #[test]
407 fn update_increments_samples_seen() {
408 let mut b = make_bandit(0.1);
409 b.update(0, 1.0).unwrap();
410 b.update(1, 0.5).unwrap();
411 b.update(2, 0.0).unwrap();
412 assert_eq!(b.samples_seen(), 3);
413 }
414
415 #[test]
416 fn update_rejects_invalid_arm() {
417 let mut b = make_bandit(0.1);
418 assert!(b.update(3, 1.0).is_err());
419 }
420
421 #[test]
422 fn update_rejects_non_finite_reward() {
423 let mut b = make_bandit(0.1);
424 assert!(b.update(0, f64::NAN).is_err());
425 assert!(b.update(0, f64::INFINITY).is_err());
426 }
427
428 #[test]
429 fn update_rejects_overflow_without_mutating_state() {
430 let mut b = make_bandit(0.1);
431 b.update(0, f64::MAX).unwrap();
432 let before = b.clone();
433 assert!(b.update(0, f64::MAX).is_err());
434 assert_eq!(b.pulls(), before.pulls());
435 assert_eq!(b.total_rewards(), before.total_rewards());
436 assert_eq!(b.samples_seen(), before.samples_seen());
437 }
438
439 #[test]
440 fn arm_stats_after_updates() {
441 let mut b = make_bandit(0.1);
442 b.update(0, 1.0).unwrap();
443 b.update(0, 3.0).unwrap();
444 let stats = b.arm_stats(0).unwrap();
445 assert_eq!(stats.pulls, 2);
446 assert!((stats.total_reward - 4.0).abs() < 1e-12);
447 assert!((stats.mean_reward - 2.0).abs() < 1e-12);
448 }
449
450 #[test]
451 fn arm_stats_rejects_invalid_arm() {
452 let b = make_bandit(0.1);
453 assert!(b.arm_stats(5).is_err());
454 }
455
456 #[test]
457 fn exploitation_picks_best_arm() {
458 let mut b = make_bandit(0.0);
460 b.update(0, 0.1).unwrap();
462 b.update(1, 0.9).unwrap();
463 b.update(2, 0.5).unwrap();
464
465 let mut rng = ChaCha8Rng::seed_from_u64(0);
466 for _ in 0..20 {
467 let arm = b.select(&mut rng).unwrap();
468 assert_eq!(arm, 1, "pure exploitation should pick arm 1");
469 }
470 }
471
472 #[test]
473 fn exploration_with_epsilon_one() {
474 let mut b = make_bandit(1.0);
476 b.update(0, 0.1).unwrap();
477 b.update(1, 0.9).unwrap();
478 b.update(2, 0.5).unwrap();
479
480 let mut rng = ChaCha8Rng::seed_from_u64(7);
481 let mut arms_seen = std::collections::HashSet::new();
482 for _ in 0..100 {
483 let arm = b.select(&mut rng).unwrap();
484 arms_seen.insert(arm);
485 }
486 assert_eq!(arms_seen.len(), 3);
488 }
489
490 #[test]
491 fn epsilon_decay_reduces_exploration() {
492 let mut b = EpsilonGreedy::new(
493 3,
494 EpsilonGreedyConfig {
495 epsilon: 0.5,
496 decay: 0.9,
497 min_epsilon: 0.01,
498 },
499 )
500 .unwrap();
501 assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
502
503 for _ in 0..10 {
504 b.update(0, 1.0).unwrap();
505 }
506 assert!(b.current_epsilon() < 0.5);
508 assert!(b.current_epsilon() > 0.01);
509 }
510
511 #[test]
512 fn epsilon_decay_respects_min_epsilon() {
513 let mut b = EpsilonGreedy::new(
514 2,
515 EpsilonGreedyConfig {
516 epsilon: 0.5,
517 decay: 0.1,
518 min_epsilon: 0.2,
519 },
520 )
521 .unwrap();
522 b.update(0, 1.0).unwrap();
524 assert!((b.current_epsilon() - 0.2).abs() < 1e-12);
525 }
526
527 #[test]
528 fn reset_clears_state() {
529 let mut b = EpsilonGreedy::new(
530 3,
531 EpsilonGreedyConfig {
532 epsilon: 0.5,
533 decay: 0.9,
534 min_epsilon: 0.01,
535 },
536 )
537 .unwrap();
538 b.update(0, 1.0).unwrap();
539 b.update(1, 0.5).unwrap();
540 assert_eq!(b.samples_seen(), 2);
541 assert!(b.current_epsilon() < 0.5);
542
543 b.reset();
544 assert_eq!(b.samples_seen(), 0);
545 assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
546 for &pulls in b.pulls() {
547 assert_eq!(pulls, 0);
548 }
549 }
550
551 #[test]
552 fn best_arm_tie_breaks_by_lowest_index() {
553 let mut b = make_bandit(0.0);
554 b.update(0, 1.0).unwrap();
555 b.update(1, 1.0).unwrap();
556 b.update(2, 0.5).unwrap();
557 let mut rng = ChaCha8Rng::seed_from_u64(0);
559 let arm = b.select(&mut rng).unwrap();
560 assert_eq!(arm, 0);
561 }
562
563 #[test]
564 fn finds_best_arm_in_simulation() {
565 let mut b = make_bandit(0.1);
566 let mut rng = ChaCha8Rng::seed_from_u64(42);
567
568 for _ in 0..500 {
570 let arm = b.select(&mut rng).unwrap();
571 let reward = match arm {
572 0 => 0.8,
573 1 => 0.3,
574 _ => 0.5,
575 };
576 b.update(arm, reward).unwrap();
577 }
578
579 let stats0 = b.arm_stats(0).unwrap();
581 let stats1 = b.arm_stats(1).unwrap();
582 let stats2 = b.arm_stats(2).unwrap();
583 assert!(stats0.mean_reward > stats1.mean_reward);
584 assert!(stats0.mean_reward > stats2.mean_reward);
585 assert!(stats0.pulls > stats1.pulls);
587 assert!(stats0.pulls > stats2.pulls);
588 }
589
590 #[cfg(feature = "serde")]
591 #[test]
592 fn serde_roundtrip() {
593 let mut b = EpsilonGreedy::new(
594 3,
595 EpsilonGreedyConfig {
596 epsilon: 0.2,
597 decay: 0.95,
598 min_epsilon: 0.01,
599 },
600 )
601 .unwrap();
602 b.update(0, 1.0).unwrap();
603 b.update(1, 0.5).unwrap();
604
605 let json = serde_json::to_string(&b).unwrap();
606 let restored: EpsilonGreedy = serde_json::from_str(&json).unwrap();
607 assert_eq!(restored.arm_count(), b.arm_count());
608 assert_eq!(restored.samples_seen(), b.samples_seen());
609 assert!((restored.current_epsilon() - b.current_epsilon()).abs() < 1e-12);
610 assert_eq!(restored.pulls(), b.pulls());
611 }
612
613 #[cfg(feature = "serde")]
614 #[test]
615 fn serde_rejects_malformed_state() {
616 let json = r#"{
617 "arm_count": 2,
618 "config": {"epsilon": 0.1, "decay": 1.0, "min_epsilon": 0.01},
619 "pulls": [1],
620 "total_rewards": [1.0],
621 "samples_seen": 1,
622 "current_epsilon": 0.1
623 }"#;
624 assert!(serde_json::from_str::<EpsilonGreedy>(json).is_err());
625 }
626}