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 self.pulls.fill(0);
239 self.total_rewards.fill(0.0);
240 self.samples_seen = 0;
241 }
242
243 fn arm_stats(&self, arm: usize) -> Result<ArmStats, RillError> {
244 validate_arm(self.arm_count, arm)?;
245 ArmStats::new(self.pulls[arm], self.total_rewards[arm])
246 }
247}
248
249#[cfg(feature = "serde")]
250#[derive(serde::Deserialize)]
251struct Ucb1State {
252 arm_count: usize,
253 config: Ucb1Config,
254 pulls: Vec<u64>,
255 total_rewards: Vec<f64>,
256 samples_seen: u64,
257}
258
259#[cfg(feature = "serde")]
260impl<'de> serde::Deserialize<'de> for Ucb1 {
261 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262 where
263 D: serde::Deserializer<'de>,
264 {
265 let state = Ucb1State::deserialize(deserializer)?;
266 let bandit = Self {
267 arm_count: state.arm_count,
268 config: state.config,
269 pulls: state.pulls,
270 total_rewards: state.total_rewards,
271 samples_seen: state.samples_seen,
272 };
273 bandit.validate().map_err(serde::de::Error::custom)?;
274 Ok(bandit)
275 }
276}
277
278#[cfg(feature = "serde")]
279impl ValidateState for Ucb1 {
280 fn validate_state(&self) -> Result<(), RillError> {
281 Ucb1::validate(self)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use rand::SeedableRng;
289 use rand_chacha::ChaCha8Rng;
290
291 fn make_bandit() -> Ucb1 {
292 Ucb1::new(3, Ucb1Config::default()).unwrap()
293 }
294
295 #[test]
296 fn rejects_zero_arm_count() {
297 let result = Ucb1::new(0, Ucb1Config::default());
298 assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
299 }
300
301 #[test]
302 fn rejects_invalid_exploration_constant() {
303 for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
304 let result = Ucb1::new(
305 3,
306 Ucb1Config {
307 exploration_constant: bad,
308 },
309 );
310 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
311 }
312 }
313
314 #[test]
315 fn initial_state() {
316 let b = make_bandit();
317 assert_eq!(b.arm_count(), 3);
318 assert_eq!(b.samples_seen(), 0);
319 }
320
321 #[test]
322 fn unpulled_arms_selected_first() {
323 let b = make_bandit();
324 let mut rng = ChaCha8Rng::seed_from_u64(42);
325 let arm = b.select(&mut rng).unwrap();
327 assert!(arm < 3);
328 }
329
330 #[test]
331 fn unexplored_arms_prioritized() {
332 let mut b = make_bandit();
333 b.update(0, 1.0).unwrap();
335 b.update(1, 0.5).unwrap();
336
337 let mut rng = ChaCha8Rng::seed_from_u64(0);
338 let arm = b.select(&mut rng).unwrap();
340 assert_eq!(arm, 2);
341 }
342
343 #[test]
344 fn all_arms_explored_uses_ucb_formula() {
345 let mut b = make_bandit();
346 b.update(0, 0.9).unwrap();
348 b.update(1, 0.3).unwrap();
349 b.update(2, 0.5).unwrap();
350
351 let mut rng = ChaCha8Rng::seed_from_u64(0);
354 let arm = b.select(&mut rng).unwrap();
355 assert_eq!(arm, 0);
356 }
357
358 #[test]
359 fn ucb_value_for_unpulled_arm_is_infinity() {
360 let b = make_bandit();
361 assert!(b.ucb_value(0).is_infinite());
362 }
363
364 #[test]
365 fn ucb_value_decreases_with_more_pulls() {
366 let mut b = make_bandit();
367 b.update(0, 1.0).unwrap();
369 b.update(1, 0.5).unwrap();
370 b.update(2, 0.5).unwrap();
371 let v1 = b.ucb_value(0);
372 for _ in 0..10 {
374 b.update(0, 1.0).unwrap();
375 }
376 let v2 = b.ucb_value(0);
377 assert!(v2 < v1);
379 }
380
381 #[test]
382 fn update_rejects_invalid_arm() {
383 let mut b = make_bandit();
384 assert!(b.update(3, 1.0).is_err());
385 }
386
387 #[test]
388 fn update_rejects_reward_outside_unit_interval() {
389 let mut b = make_bandit();
390 assert!(b.update(0, f64::NAN).is_err());
391 assert!(b.update(0, -0.1).is_err());
392 assert!(b.update(0, 1.1).is_err());
393 }
394
395 #[test]
396 fn reset_clears_state() {
397 let mut b = make_bandit();
398 b.update(0, 1.0).unwrap();
399 b.update(1, 0.5).unwrap();
400 assert_eq!(b.samples_seen(), 2);
401
402 b.reset();
403 assert_eq!(b.samples_seen(), 0);
404 for &pulls in b.pulls() {
405 assert_eq!(pulls, 0);
406 }
407 }
408
409 #[test]
410 fn finds_best_arm_in_simulation() {
411 let mut b = make_bandit();
412 let mut rng = ChaCha8Rng::seed_from_u64(42);
413
414 for _ in 0..500 {
416 let arm = b.select(&mut rng).unwrap();
417 let reward = match arm {
418 0 => 0.8,
419 1 => 0.3,
420 _ => 0.5,
421 };
422 b.update(arm, reward).unwrap();
423 }
424
425 let stats0 = b.arm_stats(0).unwrap();
427 let stats1 = b.arm_stats(1).unwrap();
428 let stats2 = b.arm_stats(2).unwrap();
429 assert!(stats0.pulls > stats1.pulls);
430 assert!(stats0.pulls > stats2.pulls);
431 assert!(stats0.mean_reward > stats1.mean_reward);
432 }
433
434 #[test]
435 fn arm_stats_rejects_invalid_arm() {
436 let b = make_bandit();
437 assert!(b.arm_stats(5).is_err());
438 }
439
440 #[cfg(feature = "serde")]
441 #[test]
442 fn serde_roundtrip() {
443 let mut b = Ucb1::new(
444 3,
445 Ucb1Config {
446 exploration_constant: 2.0,
447 },
448 )
449 .unwrap();
450 b.update(0, 1.0).unwrap();
451 b.update(1, 0.5).unwrap();
452
453 let json = serde_json::to_string(&b).unwrap();
454 let restored: Ucb1 = serde_json::from_str(&json).unwrap();
455 assert_eq!(restored.arm_count(), b.arm_count());
456 assert_eq!(restored.samples_seen(), b.samples_seen());
457 assert_eq!(restored.pulls(), b.pulls());
458 }
459
460 #[cfg(feature = "serde")]
461 #[test]
462 fn serde_rejects_malformed_state() {
463 let json = r#"{
464 "arm_count": 2,
465 "config": {"exploration_constant": 1.0},
466 "pulls": [1],
467 "total_rewards": [1.0],
468 "samples_seen": 1
469 }"#;
470 assert!(serde_json::from_str::<Ucb1>(json).is_err());
471 }
472}