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 self.pulls.fill(0);
271 self.total_rewards.fill(0.0);
272 self.samples_seen = 0;
273 self.current_epsilon = self.config.epsilon;
274 }
275
276 fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
277 validate_arm(self.arm_count, arm)?;
278 ArmStats::new(self.pulls[arm], self.total_rewards[arm])
279 }
280}
281
282#[cfg(feature = "serde")]
283#[derive(serde::Deserialize)]
284struct EpsilonGreedyState {
285 arm_count: usize,
286 config: EpsilonGreedyConfig,
287 pulls: Vec<u64>,
288 total_rewards: Vec<f64>,
289 samples_seen: u64,
290 current_epsilon: f64,
291}
292
293#[cfg(feature = "serde")]
294impl<'de> serde::Deserialize<'de> for EpsilonGreedy {
295 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
296 where
297 D: serde::Deserializer<'de>,
298 {
299 let state = EpsilonGreedyState::deserialize(deserializer)?;
300 let bandit = Self {
301 arm_count: state.arm_count,
302 config: state.config,
303 pulls: state.pulls,
304 total_rewards: state.total_rewards,
305 samples_seen: state.samples_seen,
306 current_epsilon: state.current_epsilon,
307 };
308 bandit.validate().map_err(serde::de::Error::custom)?;
309 Ok(bandit)
310 }
311}
312
313#[cfg(feature = "serde")]
314impl ValidateState for EpsilonGreedy {
315 fn validate_state(&self) -> Result<(), RillError> {
316 EpsilonGreedy::validate(self)
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use rand::SeedableRng;
324 use rand_chacha::ChaCha8Rng;
325
326 fn make_bandit(epsilon: f64) -> EpsilonGreedy {
327 EpsilonGreedy::new(
328 3,
329 EpsilonGreedyConfig {
330 epsilon,
331 decay: 1.0,
332 min_epsilon: 0.0,
333 },
334 )
335 .unwrap()
336 }
337
338 #[test]
339 fn rejects_zero_arm_count() {
340 let result = EpsilonGreedy::new(0, EpsilonGreedyConfig::default());
341 assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
342 }
343
344 #[test]
345 fn rejects_invalid_epsilon() {
346 let result = EpsilonGreedy::new(
347 3,
348 EpsilonGreedyConfig {
349 epsilon: 1.5,
350 decay: 1.0,
351 min_epsilon: 0.0,
352 },
353 );
354 assert!(matches!(result, Err(RillError::InvalidEpsilon(_))));
355 }
356
357 #[test]
358 fn rejects_invalid_decay() {
359 let result = EpsilonGreedy::new(
360 3,
361 EpsilonGreedyConfig {
362 epsilon: 0.1,
363 decay: 0.0,
364 min_epsilon: 0.0,
365 },
366 );
367 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
368 }
369
370 #[test]
371 fn rejects_invalid_min_epsilon() {
372 let result = EpsilonGreedy::new(
373 3,
374 EpsilonGreedyConfig {
375 epsilon: 0.1,
376 decay: 1.0,
377 min_epsilon: 0.5, },
379 );
380 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
381 }
382
383 #[test]
384 fn initial_state() {
385 let b = make_bandit(0.1);
386 assert_eq!(b.arm_count(), 3);
387 assert_eq!(b.samples_seen(), 0);
388 assert!((b.current_epsilon() - 0.1).abs() < 1e-12);
389 for &pulls in b.pulls() {
390 assert_eq!(pulls, 0);
391 }
392 }
393
394 #[test]
395 fn select_with_no_data_returns_valid_arm() {
396 let b = make_bandit(0.1);
397 let mut rng = ChaCha8Rng::seed_from_u64(42);
398 let arm = b.select(&mut rng).unwrap();
399 assert!(arm < 3);
400 }
401
402 #[test]
403 fn update_increments_samples_seen() {
404 let mut b = make_bandit(0.1);
405 b.update(0, 1.0).unwrap();
406 b.update(1, 0.5).unwrap();
407 b.update(2, 0.0).unwrap();
408 assert_eq!(b.samples_seen(), 3);
409 }
410
411 #[test]
412 fn update_rejects_invalid_arm() {
413 let mut b = make_bandit(0.1);
414 assert!(b.update(3, 1.0).is_err());
415 }
416
417 #[test]
418 fn update_rejects_non_finite_reward() {
419 let mut b = make_bandit(0.1);
420 assert!(b.update(0, f64::NAN).is_err());
421 assert!(b.update(0, f64::INFINITY).is_err());
422 }
423
424 #[test]
425 fn update_rejects_overflow_without_mutating_state() {
426 let mut b = make_bandit(0.1);
427 b.update(0, f64::MAX).unwrap();
428 let before = b.clone();
429 assert!(b.update(0, f64::MAX).is_err());
430 assert_eq!(b.pulls(), before.pulls());
431 assert_eq!(b.total_rewards(), before.total_rewards());
432 assert_eq!(b.samples_seen(), before.samples_seen());
433 }
434
435 #[test]
436 fn arm_stats_after_updates() {
437 let mut b = make_bandit(0.1);
438 b.update(0, 1.0).unwrap();
439 b.update(0, 3.0).unwrap();
440 let stats = b.arm_stats(0).unwrap();
441 assert_eq!(stats.pulls, 2);
442 assert!((stats.total_reward - 4.0).abs() < 1e-12);
443 assert!((stats.mean_reward - 2.0).abs() < 1e-12);
444 }
445
446 #[test]
447 fn arm_stats_rejects_invalid_arm() {
448 let b = make_bandit(0.1);
449 assert!(b.arm_stats(5).is_err());
450 }
451
452 #[test]
453 fn exploitation_picks_best_arm() {
454 let mut b = make_bandit(0.0);
456 b.update(0, 0.1).unwrap();
458 b.update(1, 0.9).unwrap();
459 b.update(2, 0.5).unwrap();
460
461 let mut rng = ChaCha8Rng::seed_from_u64(0);
462 for _ in 0..20 {
463 let arm = b.select(&mut rng).unwrap();
464 assert_eq!(arm, 1, "pure exploitation should pick arm 1");
465 }
466 }
467
468 #[test]
469 fn exploration_with_epsilon_one() {
470 let mut b = make_bandit(1.0);
472 b.update(0, 0.1).unwrap();
473 b.update(1, 0.9).unwrap();
474 b.update(2, 0.5).unwrap();
475
476 let mut rng = ChaCha8Rng::seed_from_u64(7);
477 let mut arms_seen = std::collections::HashSet::new();
478 for _ in 0..100 {
479 let arm = b.select(&mut rng).unwrap();
480 arms_seen.insert(arm);
481 }
482 assert_eq!(arms_seen.len(), 3);
484 }
485
486 #[test]
487 fn epsilon_decay_reduces_exploration() {
488 let mut b = EpsilonGreedy::new(
489 3,
490 EpsilonGreedyConfig {
491 epsilon: 0.5,
492 decay: 0.9,
493 min_epsilon: 0.01,
494 },
495 )
496 .unwrap();
497 assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
498
499 for _ in 0..10 {
500 b.update(0, 1.0).unwrap();
501 }
502 assert!(b.current_epsilon() < 0.5);
504 assert!(b.current_epsilon() > 0.01);
505 }
506
507 #[test]
508 fn epsilon_decay_respects_min_epsilon() {
509 let mut b = EpsilonGreedy::new(
510 2,
511 EpsilonGreedyConfig {
512 epsilon: 0.5,
513 decay: 0.1,
514 min_epsilon: 0.2,
515 },
516 )
517 .unwrap();
518 b.update(0, 1.0).unwrap();
520 assert!((b.current_epsilon() - 0.2).abs() < 1e-12);
521 }
522
523 #[test]
524 fn reset_clears_state() {
525 let mut b = EpsilonGreedy::new(
526 3,
527 EpsilonGreedyConfig {
528 epsilon: 0.5,
529 decay: 0.9,
530 min_epsilon: 0.01,
531 },
532 )
533 .unwrap();
534 b.update(0, 1.0).unwrap();
535 b.update(1, 0.5).unwrap();
536 assert_eq!(b.samples_seen(), 2);
537 assert!(b.current_epsilon() < 0.5);
538
539 b.reset();
540 assert_eq!(b.samples_seen(), 0);
541 assert!((b.current_epsilon() - 0.5).abs() < 1e-12);
542 for &pulls in b.pulls() {
543 assert_eq!(pulls, 0);
544 }
545 }
546
547 #[test]
548 fn best_arm_tie_breaks_by_lowest_index() {
549 let mut b = make_bandit(0.0);
550 b.update(0, 1.0).unwrap();
551 b.update(1, 1.0).unwrap();
552 b.update(2, 0.5).unwrap();
553 let mut rng = ChaCha8Rng::seed_from_u64(0);
555 let arm = b.select(&mut rng).unwrap();
556 assert_eq!(arm, 0);
557 }
558
559 #[test]
560 fn finds_best_arm_in_simulation() {
561 let mut b = make_bandit(0.1);
562 let mut rng = ChaCha8Rng::seed_from_u64(42);
563
564 for _ in 0..500 {
566 let arm = b.select(&mut rng).unwrap();
567 let reward = match arm {
568 0 => 0.8,
569 1 => 0.3,
570 _ => 0.5,
571 };
572 b.update(arm, reward).unwrap();
573 }
574
575 let stats0 = b.arm_stats(0).unwrap();
577 let stats1 = b.arm_stats(1).unwrap();
578 let stats2 = b.arm_stats(2).unwrap();
579 assert!(stats0.mean_reward > stats1.mean_reward);
580 assert!(stats0.mean_reward > stats2.mean_reward);
581 assert!(stats0.pulls > stats1.pulls);
583 assert!(stats0.pulls > stats2.pulls);
584 }
585
586 #[cfg(feature = "serde")]
587 #[test]
588 fn serde_roundtrip() {
589 let mut b = EpsilonGreedy::new(
590 3,
591 EpsilonGreedyConfig {
592 epsilon: 0.2,
593 decay: 0.95,
594 min_epsilon: 0.01,
595 },
596 )
597 .unwrap();
598 b.update(0, 1.0).unwrap();
599 b.update(1, 0.5).unwrap();
600
601 let json = serde_json::to_string(&b).unwrap();
602 let restored: EpsilonGreedy = serde_json::from_str(&json).unwrap();
603 assert_eq!(restored.arm_count(), b.arm_count());
604 assert_eq!(restored.samples_seen(), b.samples_seen());
605 assert!((restored.current_epsilon() - b.current_epsilon()).abs() < 1e-12);
606 assert_eq!(restored.pulls(), b.pulls());
607 }
608
609 #[cfg(feature = "serde")]
610 #[test]
611 fn serde_rejects_malformed_state() {
612 let json = r#"{
613 "arm_count": 2,
614 "config": {"epsilon": 0.1, "decay": 1.0, "min_epsilon": 0.01},
615 "pulls": [1],
616 "total_rewards": [1.0],
617 "samples_seen": 1,
618 "current_epsilon": 0.1
619 }"#;
620 assert!(serde_json::from_str::<EpsilonGreedy>(json).is_err());
621 }
622}