1use crate::bandit::stats::ArmStats;
26use crate::bandit::{
27 Bandit, checked_finite_add, checked_increment, validate_arm, validate_reward_01,
28 validate_sample_count,
29};
30use crate::error::RillError;
31#[cfg(feature = "serde")]
32use crate::persistence::ValidateState;
33use rand::Rng;
34
35#[derive(Debug, Clone, PartialEq)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48#[non_exhaustive]
49pub struct Ucb1Config {
50 pub exploration_constant: f64,
56}
57
58impl Default for Ucb1Config {
59 fn default() -> Self {
60 Self {
61 exploration_constant: 1.0,
62 }
63 }
64}
65
66impl Ucb1Config {
67 pub fn validate(&self) -> Result<(), RillError> {
69 if !self.exploration_constant.is_finite() || self.exploration_constant <= 0.0 {
70 return Err(RillError::InvalidParameter {
71 name: "exploration_constant",
72 value: self.exploration_constant,
73 });
74 }
75 Ok(())
76 }
77}
78
79#[derive(Debug, Clone)]
100#[cfg_attr(feature = "serde", derive(serde::Serialize))]
101pub struct Ucb1 {
102 arm_count: usize,
103 config: Ucb1Config,
104 pulls: Vec<u64>,
106 total_rewards: Vec<f64>,
108 samples_seen: u64,
110}
111
112impl Ucb1 {
113 pub fn new(arm_count: usize, config: Ucb1Config) -> Result<Self, RillError> {
121 if arm_count == 0 {
122 return Err(RillError::InvalidArmCount(arm_count));
123 }
124 config.validate()?;
125
126 Ok(Self {
127 arm_count,
128 config,
129 pulls: vec![0; arm_count],
130 total_rewards: vec![0.0; arm_count],
131 samples_seen: 0,
132 })
133 }
134
135 pub fn pulls(&self) -> &[u64] {
137 &self.pulls
138 }
139
140 pub fn total_rewards(&self) -> &[f64] {
142 &self.total_rewards
143 }
144
145 pub fn validate(&self) -> Result<(), RillError> {
149 if self.arm_count == 0 {
150 return Err(RillError::InvalidArmCount(self.arm_count));
151 }
152 self.config.validate()?;
153 if self.pulls.len() != self.arm_count || self.total_rewards.len() != self.arm_count {
154 return Err(RillError::InvalidState(
155 "arm_count does not match per-arm state lengths".to_owned(),
156 ));
157 }
158 validate_sample_count(&self.pulls, self.samples_seen)?;
159 for (arm, (&pulls, &reward)) in self.pulls.iter().zip(self.total_rewards.iter()).enumerate()
160 {
161 if !reward.is_finite() || reward < 0.0 || reward > pulls as f64 {
162 return Err(RillError::InvalidState(format!(
163 "total reward for arm {arm} is inconsistent with [0, 1] rewards"
164 )));
165 }
166 }
167 Ok(())
168 }
169
170 fn ucb_value(&self, arm: usize) -> f64 {
174 let pulls = self.pulls[arm];
175 if pulls == 0 {
176 return f64::INFINITY;
177 }
178 let mean = self.total_rewards[arm] / pulls as f64;
179 let log_total = (self.samples_seen as f64).ln();
182 let exploration =
183 self.config.exploration_constant * (2.0 * log_total / pulls as f64).sqrt();
184 mean + exploration
185 }
186}
187
188impl Bandit for Ucb1 {
189 fn arm_count(&self) -> usize {
190 self.arm_count
191 }
192
193 fn samples_seen(&self) -> u64 {
194 self.samples_seen
195 }
196
197 fn select(&self, rng: &mut impl Rng) -> Result<usize, RillError> {
198 let mut best_arm = 0usize;
200 let mut best_value = f64::NEG_INFINITY;
201 let mut unexplored: Vec<usize> = Vec::new();
202
203 for arm in 0..self.arm_count {
204 if self.pulls[arm] == 0 {
205 unexplored.push(arm);
206 continue;
207 }
208 let value = self.ucb_value(arm);
209 if value > best_value {
210 best_value = value;
211 best_arm = arm;
212 }
213 }
214
215 if !unexplored.is_empty() {
217 let idx = rng.gen_range(0..unexplored.len());
218 return Ok(unexplored[idx]);
219 }
220
221 Ok(best_arm)
222 }
223
224 fn update(&mut self, arm: usize, reward: f64) -> Result<(), RillError> {
225 validate_arm(self.arm_count, arm)?;
226 validate_reward_01(reward)?;
227
228 let next_pulls = checked_increment(self.pulls[arm], "pulls")?;
229 let next_total = checked_finite_add(self.total_rewards[arm], reward, "total_rewards")?;
230 let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
231 self.pulls[arm] = next_pulls;
232 self.total_rewards[arm] = next_total;
233 self.samples_seen = next_samples;
234 Ok(())
235 }
236
237 fn reset(&mut self) {
238 for p in &mut self.pulls {
239 *p = 0;
240 }
241 for r in &mut self.total_rewards {
242 *r = 0.0;
243 }
244 self.samples_seen = 0;
245 }
246
247 fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
248 validate_arm(self.arm_count, arm)?;
249 ArmStats::new(self.pulls[arm], self.total_rewards[arm])
250 }
251}
252
253#[cfg(feature = "serde")]
254#[derive(serde::Deserialize)]
255struct Ucb1State {
256 arm_count: usize,
257 config: Ucb1Config,
258 pulls: Vec<u64>,
259 total_rewards: Vec<f64>,
260 samples_seen: u64,
261}
262
263#[cfg(feature = "serde")]
264impl<'de> serde::Deserialize<'de> for Ucb1 {
265 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
266 where
267 D: serde::Deserializer<'de>,
268 {
269 let state = Ucb1State::deserialize(deserializer)?;
270 let bandit = Self {
271 arm_count: state.arm_count,
272 config: state.config,
273 pulls: state.pulls,
274 total_rewards: state.total_rewards,
275 samples_seen: state.samples_seen,
276 };
277 bandit.validate().map_err(serde::de::Error::custom)?;
278 Ok(bandit)
279 }
280}
281
282#[cfg(feature = "serde")]
283impl ValidateState for Ucb1 {
284 fn validate_state(&self) -> Result<(), RillError> {
285 Ucb1::validate(self)
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use rand::SeedableRng;
293 use rand_chacha::ChaCha8Rng;
294
295 fn make_bandit() -> Ucb1 {
296 Ucb1::new(3, Ucb1Config::default()).unwrap()
297 }
298
299 #[test]
300 fn rejects_zero_arm_count() {
301 let result = Ucb1::new(0, Ucb1Config::default());
302 assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
303 }
304
305 #[test]
306 fn rejects_invalid_exploration_constant() {
307 for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
308 let result = Ucb1::new(
309 3,
310 Ucb1Config {
311 exploration_constant: bad,
312 },
313 );
314 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
315 }
316 }
317
318 #[test]
319 fn initial_state() {
320 let b = make_bandit();
321 assert_eq!(b.arm_count(), 3);
322 assert_eq!(b.samples_seen(), 0);
323 }
324
325 #[test]
326 fn unpulled_arms_selected_first() {
327 let b = make_bandit();
328 let mut rng = ChaCha8Rng::seed_from_u64(42);
329 let arm = b.select(&mut rng).unwrap();
331 assert!(arm < 3);
332 }
333
334 #[test]
335 fn unexplored_arms_prioritized() {
336 let mut b = make_bandit();
337 b.update(0, 1.0).unwrap();
339 b.update(1, 0.5).unwrap();
340
341 let mut rng = ChaCha8Rng::seed_from_u64(0);
342 let arm = b.select(&mut rng).unwrap();
344 assert_eq!(arm, 2);
345 }
346
347 #[test]
348 fn all_arms_explored_uses_ucb_formula() {
349 let mut b = make_bandit();
350 b.update(0, 0.9).unwrap();
352 b.update(1, 0.3).unwrap();
353 b.update(2, 0.5).unwrap();
354
355 let mut rng = ChaCha8Rng::seed_from_u64(0);
358 let arm = b.select(&mut rng).unwrap();
359 assert_eq!(arm, 0);
360 }
361
362 #[test]
363 fn ucb_value_for_unpulled_arm_is_infinity() {
364 let b = make_bandit();
365 assert!(b.ucb_value(0).is_infinite());
366 }
367
368 #[test]
369 fn ucb_value_decreases_with_more_pulls() {
370 let mut b = make_bandit();
371 b.update(0, 1.0).unwrap();
373 b.update(1, 0.5).unwrap();
374 b.update(2, 0.5).unwrap();
375 let v1 = b.ucb_value(0);
376 for _ in 0..10 {
378 b.update(0, 1.0).unwrap();
379 }
380 let v2 = b.ucb_value(0);
381 assert!(v2 < v1);
383 }
384
385 #[test]
386 fn update_rejects_invalid_arm() {
387 let mut b = make_bandit();
388 assert!(b.update(3, 1.0).is_err());
389 }
390
391 #[test]
392 fn update_rejects_reward_outside_unit_interval() {
393 let mut b = make_bandit();
394 assert!(b.update(0, f64::NAN).is_err());
395 assert!(b.update(0, -0.1).is_err());
396 assert!(b.update(0, 1.1).is_err());
397 }
398
399 #[test]
400 fn reset_clears_state() {
401 let mut b = make_bandit();
402 b.update(0, 1.0).unwrap();
403 b.update(1, 0.5).unwrap();
404 assert_eq!(b.samples_seen(), 2);
405
406 b.reset();
407 assert_eq!(b.samples_seen(), 0);
408 for &pulls in b.pulls() {
409 assert_eq!(pulls, 0);
410 }
411 }
412
413 #[test]
414 fn finds_best_arm_in_simulation() {
415 let mut b = make_bandit();
416 let mut rng = ChaCha8Rng::seed_from_u64(42);
417
418 for _ in 0..500 {
420 let arm = b.select(&mut rng).unwrap();
421 let reward = match arm {
422 0 => 0.8,
423 1 => 0.3,
424 _ => 0.5,
425 };
426 b.update(arm, reward).unwrap();
427 }
428
429 let stats0 = b.arm_stats(0).unwrap();
431 let stats1 = b.arm_stats(1).unwrap();
432 let stats2 = b.arm_stats(2).unwrap();
433 assert!(stats0.pulls > stats1.pulls);
434 assert!(stats0.pulls > stats2.pulls);
435 assert!(stats0.mean_reward > stats1.mean_reward);
436 }
437
438 #[test]
439 fn arm_stats_rejects_invalid_arm() {
440 let b = make_bandit();
441 assert!(b.arm_stats(5).is_err());
442 }
443
444 #[cfg(feature = "serde")]
445 #[test]
446 fn serde_roundtrip() {
447 let mut b = Ucb1::new(
448 3,
449 Ucb1Config {
450 exploration_constant: 2.0,
451 },
452 )
453 .unwrap();
454 b.update(0, 1.0).unwrap();
455 b.update(1, 0.5).unwrap();
456
457 let json = serde_json::to_string(&b).unwrap();
458 let restored: Ucb1 = serde_json::from_str(&json).unwrap();
459 assert_eq!(restored.arm_count(), b.arm_count());
460 assert_eq!(restored.samples_seen(), b.samples_seen());
461 assert_eq!(restored.pulls(), b.pulls());
462 }
463
464 #[cfg(feature = "serde")]
465 #[test]
466 fn serde_rejects_malformed_state() {
467 let json = r#"{
468 "arm_count": 2,
469 "config": {"exploration_constant": 1.0},
470 "pulls": [1],
471 "total_rewards": [1.0],
472 "samples_seen": 1
473 }"#;
474 assert!(serde_json::from_str::<Ucb1>(json).is_err());
475 }
476}