1use crate::bandit::{
35 ContextualBandit, checked_finite_add, checked_increment, validate_arm, validate_reward_finite,
36};
37use crate::error::{RillError, ensure_finite};
38use crate::persistence::ValidateState;
39use rand::Rng;
40
41#[derive(Debug, Clone, PartialEq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[non_exhaustive]
57pub struct LinUcbConfig {
58 pub alpha: f64,
63 pub arm_count: usize,
65 pub feature_count: usize,
67}
68
69impl Default for LinUcbConfig {
70 fn default() -> Self {
71 Self {
72 alpha: 1.0,
73 arm_count: 2,
74 feature_count: 1,
75 }
76 }
77}
78
79impl LinUcbConfig {
80 pub fn validate(&self) -> Result<(), RillError> {
82 if self.arm_count == 0 {
83 return Err(RillError::InvalidArmCount(self.arm_count));
84 }
85 if self.feature_count == 0 {
86 return Err(RillError::InvalidFeatureCount(self.feature_count));
87 }
88 if !self.alpha.is_finite() || self.alpha <= 0.0 {
89 return Err(RillError::InvalidParameter {
90 name: "alpha",
91 value: self.alpha,
92 });
93 }
94 Ok(())
95 }
96}
97
98#[derive(Debug, Clone, PartialEq)]
104pub struct LinUcbArmScore {
105 pub arm: usize,
107 pub exploitation: f64,
109 pub exploration_bonus: f64,
111 pub total_score: f64,
113}
114
115#[derive(Debug, Clone, PartialEq)]
117pub struct LinUcbConditionDiagnostics {
118 pub arm: usize,
120 pub min_cholesky_diagonal: f64,
122 pub max_cholesky_diagonal: f64,
124 pub condition_indicator: f64,
129}
130
131#[derive(Debug, Clone)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize))]
158pub struct LinUcb {
159 arm_count: usize,
160 feature_count: usize,
161 alpha: f64,
162 a_matrices: Vec<Vec<Vec<f64>>>,
164 b_vectors: Vec<Vec<f64>>,
166 samples_seen: u64,
168}
169
170impl LinUcb {
171 pub fn new(config: LinUcbConfig) -> Result<Self, RillError> {
180 config.validate()?;
181
182 let d = config.feature_count;
183 let a_matrices = (0..config.arm_count).map(|_| identity_matrix(d)).collect();
184 let b_vectors = (0..config.arm_count).map(|_| vec![0.0; d]).collect();
185
186 Ok(Self {
187 arm_count: config.arm_count,
188 feature_count: config.feature_count,
189 alpha: config.alpha,
190 a_matrices,
191 b_vectors,
192 samples_seen: 0,
193 })
194 }
195
196 pub const fn alpha(&self) -> f64 {
198 self.alpha
199 }
200
201 pub fn a_matrix(&self, arm: usize) -> Result<&[Vec<f64>], RillError> {
207 validate_arm(self.arm_count, arm)?;
208 Ok(&self.a_matrices[arm])
209 }
210
211 pub fn b_vector(&self, arm: usize) -> Result<&[f64], RillError> {
217 validate_arm(self.arm_count, arm)?;
218 Ok(&self.b_vectors[arm])
219 }
220
221 pub fn validate(&self) -> Result<(), RillError> {
225 LinUcbConfig {
226 alpha: self.alpha,
227 arm_count: self.arm_count,
228 feature_count: self.feature_count,
229 }
230 .validate()?;
231 if self.a_matrices.len() != self.arm_count || self.b_vectors.len() != self.arm_count {
232 return Err(RillError::InvalidState(
233 "arm_count does not match per-arm state lengths".to_owned(),
234 ));
235 }
236
237 for arm in 0..self.arm_count {
238 let matrix = &self.a_matrices[arm];
239 let vector = &self.b_vectors[arm];
240 if matrix.len() != self.feature_count
241 || matrix.iter().any(|row| row.len() != self.feature_count)
242 || vector.len() != self.feature_count
243 {
244 return Err(RillError::InvalidState(format!(
245 "arm {arm} state does not match feature_count"
246 )));
247 }
248 if matrix.iter().flatten().any(|value| !value.is_finite())
249 || vector.iter().any(|value| !value.is_finite())
250 {
251 return Err(RillError::InvalidState(format!(
252 "arm {arm} state contains a non-finite value"
253 )));
254 }
255 for (i, row) in matrix.iter().enumerate() {
256 for (j, &value) in row.iter().take(i).enumerate() {
257 if value != matrix[j][i] {
258 return Err(RillError::InvalidState(format!(
259 "A matrix for arm {arm} is not symmetric"
260 )));
261 }
262 }
263 }
264 if !matrix_is_positive_definite(matrix) {
265 return Err(RillError::InvalidState(format!(
266 "A matrix for arm {arm} is not positive definite"
267 )));
268 }
269 }
270 Ok(())
271 }
272
273 fn validate_context(&self, context: &[f64]) -> Result<(), RillError> {
275 if context.len() != self.feature_count {
276 return Err(RillError::DimensionMismatch {
277 expected: self.feature_count,
278 actual: context.len(),
279 });
280 }
281 for (i, &v) in context.iter().enumerate() {
282 if !v.is_finite() {
283 return Err(RillError::NonFiniteValue {
284 field: "context",
285 value: context[i],
286 });
287 }
288 }
289 Ok(())
290 }
291
292 pub fn score_arm(&self, arm: usize, context: &[f64]) -> Result<LinUcbArmScore, RillError> {
296 validate_arm(self.arm_count, arm)?;
297 self.validate_context(context)?;
298 self.score_arm_validated(arm, context)
299 }
300
301 pub fn score_all(&self, context: &[f64]) -> Result<Vec<LinUcbArmScore>, RillError> {
303 self.validate_context(context)?;
304 (0..self.arm_count)
305 .map(|arm| self.score_arm_validated(arm, context))
306 .collect()
307 }
308
309 pub fn select_with_scores(
312 &self,
313 context: &[f64],
314 rng: &mut impl Rng,
315 ) -> Result<(usize, Vec<LinUcbArmScore>), RillError> {
316 let scores = self.score_all(context)?;
317 let arm = select_random_tie(&scores, rng);
318 Ok((arm, scores))
319 }
320
321 pub fn select_deterministic(&self, context: &[f64]) -> Result<usize, RillError> {
325 self.validate_context(context)?;
326 let mut best_arm = 0usize;
327 let mut best_score = f64::NEG_INFINITY;
328 for arm in 0..self.arm_count {
329 let score = self.score_arm_validated(arm, context)?.total_score;
330 if score > best_score {
331 best_score = score;
332 best_arm = arm;
333 }
334 }
335 Ok(best_arm)
336 }
337
338 pub fn condition_diagnostics(
340 &self,
341 arm: usize,
342 ) -> Result<LinUcbConditionDiagnostics, RillError> {
343 validate_arm(self.arm_count, arm)?;
344 let lower = cholesky_factor(&self.a_matrices[arm])?;
345 let mut min_diagonal = f64::INFINITY;
346 let mut max_diagonal = 0.0_f64;
347 for (i, row) in lower.iter().enumerate() {
348 min_diagonal = min_diagonal.min(row[i]);
349 max_diagonal = max_diagonal.max(row[i]);
350 }
351 let ratio = max_diagonal / min_diagonal;
352 let condition_indicator = ratio * ratio;
353 if !condition_indicator.is_finite() {
354 return Err(RillError::InvalidState(
355 "LinUCB condition indicator is non-finite".to_owned(),
356 ));
357 }
358 Ok(LinUcbConditionDiagnostics {
359 arm,
360 min_cholesky_diagonal: min_diagonal,
361 max_cholesky_diagonal: max_diagonal,
362 condition_indicator,
363 })
364 }
365
366 fn score_arm_validated(
367 &self,
368 arm: usize,
369 context: &[f64],
370 ) -> Result<LinUcbArmScore, RillError> {
371 let lower = cholesky_factor(&self.a_matrices[arm])?;
372 let b = &self.b_vectors[arm];
373 let theta = cholesky_solve(&lower, b)?;
375 let exploitation = checked_dot(&theta, context, "LinUCB exploitation")?;
377 let solved_context = cholesky_solve(&lower, context)?;
379 let quad = checked_dot(context, &solved_context, "LinUCB exploration variance")?;
380 let quad_safe = if quad < 0.0 { 0.0 } else { quad };
383 let exploration_bonus = self.alpha * quad_safe.sqrt();
384 if !exploration_bonus.is_finite() {
385 return Err(RillError::NonFiniteValue {
386 field: "LinUCB exploration bonus",
387 value: exploration_bonus,
388 });
389 }
390 let total_score =
391 checked_finite_add(exploitation, exploration_bonus, "LinUCB total score")?;
392 Ok(LinUcbArmScore {
393 arm,
394 exploitation,
395 exploration_bonus,
396 total_score,
397 })
398 }
399}
400
401impl ContextualBandit for LinUcb {
402 fn arm_count(&self) -> usize {
403 self.arm_count
404 }
405
406 fn feature_count(&self) -> usize {
407 self.feature_count
408 }
409
410 fn samples_seen(&self) -> u64 {
411 self.samples_seen
412 }
413
414 fn select(&self, context: &[f64], rng: &mut impl Rng) -> Result<usize, RillError> {
415 self.validate_context(context)?;
416 let mut best_arm = 0usize;
417 let mut best_score = f64::NEG_INFINITY;
418 let mut tied = 0usize;
419 for arm in 0..self.arm_count {
420 let score = self.score_arm_validated(arm, context)?.total_score;
421 if score > best_score {
422 best_score = score;
423 best_arm = arm;
424 tied = 1;
425 } else if score == best_score {
426 tied += 1;
427 if rng.gen_range(0..tied) == 0 {
428 best_arm = arm;
429 }
430 }
431 }
432 Ok(best_arm)
433 }
434
435 fn update(&mut self, arm: usize, context: &[f64], reward: f64) -> Result<(), RillError> {
436 validate_arm(self.arm_count, arm)?;
437 self.validate_context(context)?;
438 validate_reward_finite(reward)?;
439
440 let d = self.feature_count;
441 let mut next_a = self.a_matrices[arm].clone();
442 for i in 0..d {
443 for j in 0..d {
444 next_a[i][j] =
445 checked_finite_add(next_a[i][j], context[i] * context[j], "A matrix")?;
446 }
447 }
448 let mut next_b = self.b_vectors[arm].clone();
449 for i in 0..d {
450 next_b[i] = checked_finite_add(next_b[i], reward * context[i], "b vector")?;
451 }
452 let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
453 cholesky_factor(&next_a)?;
457
458 self.a_matrices[arm] = next_a;
459 self.b_vectors[arm] = next_b;
460 self.samples_seen = next_samples;
461 Ok(())
462 }
463
464 fn reset(&mut self) {
465 for a in &mut self.a_matrices {
466 *a = identity_matrix(self.feature_count);
467 }
468 for b in &mut self.b_vectors {
469 for v in b.iter_mut() {
470 *v = 0.0;
471 }
472 }
473 self.samples_seen = 0;
474 }
475}
476
477#[derive(Debug, Clone)]
484#[cfg_attr(feature = "serde", derive(serde::Serialize))]
485pub struct LinUcbFast {
486 arm_count: usize,
487 feature_count: usize,
488 alpha: f64,
489 inverse_matrices: Vec<Vec<Vec<f64>>>,
490 b_vectors: Vec<Vec<f64>>,
491 samples_seen: u64,
492}
493
494impl LinUcbFast {
495 pub fn new(config: LinUcbConfig) -> Result<Self, RillError> {
497 config.validate()?;
498 let inverse_matrices = (0..config.arm_count)
499 .map(|_| identity_matrix(config.feature_count))
500 .collect();
501 let b_vectors = (0..config.arm_count)
502 .map(|_| vec![0.0; config.feature_count])
503 .collect();
504 Ok(Self {
505 arm_count: config.arm_count,
506 feature_count: config.feature_count,
507 alpha: config.alpha,
508 inverse_matrices,
509 b_vectors,
510 samples_seen: 0,
511 })
512 }
513
514 pub fn from_linucb(source: &LinUcb) -> Result<Self, RillError> {
516 source.validate()?;
517 let mut inverse_matrices = Vec::with_capacity(source.arm_count);
518 for matrix in &source.a_matrices {
519 let lower = cholesky_factor(matrix)?;
520 inverse_matrices.push(inverse_from_cholesky(&lower)?);
521 }
522 let fast = Self {
523 arm_count: source.arm_count,
524 feature_count: source.feature_count,
525 alpha: source.alpha,
526 inverse_matrices,
527 b_vectors: source.b_vectors.clone(),
528 samples_seen: source.samples_seen,
529 };
530 fast.validate()?;
531 Ok(fast)
532 }
533
534 pub fn inverse_matrix(&self, arm: usize) -> Result<&[Vec<f64>], RillError> {
536 validate_arm(self.arm_count, arm)?;
537 Ok(&self.inverse_matrices[arm])
538 }
539
540 pub const fn state_f64_count(&self) -> usize {
542 self.arm_count * (self.feature_count * self.feature_count + self.feature_count) + 1
543 }
544
545 pub fn score_arm(&self, arm: usize, context: &[f64]) -> Result<LinUcbArmScore, RillError> {
547 validate_arm(self.arm_count, arm)?;
548 self.validate_context(context)?;
549 self.score_arm_validated(arm, context)
550 }
551
552 pub fn score_all(&self, context: &[f64]) -> Result<Vec<LinUcbArmScore>, RillError> {
554 self.validate_context(context)?;
555 (0..self.arm_count)
556 .map(|arm| self.score_arm_validated(arm, context))
557 .collect()
558 }
559
560 pub fn select_deterministic(&self, context: &[f64]) -> Result<usize, RillError> {
562 self.validate_context(context)?;
563 let mut best_arm = 0;
564 let mut best_score = f64::NEG_INFINITY;
565 for arm in 0..self.arm_count {
566 let score = self.score_arm_validated(arm, context)?.total_score;
567 if score > best_score {
568 best_score = score;
569 best_arm = arm;
570 }
571 }
572 Ok(best_arm)
573 }
574
575 pub fn validate(&self) -> Result<(), RillError> {
577 LinUcbConfig {
578 alpha: self.alpha,
579 arm_count: self.arm_count,
580 feature_count: self.feature_count,
581 }
582 .validate()?;
583 if self.inverse_matrices.len() != self.arm_count || self.b_vectors.len() != self.arm_count {
584 return Err(RillError::InvalidState(
585 "fast LinUCB arm state lengths are inconsistent".to_owned(),
586 ));
587 }
588 for arm in 0..self.arm_count {
589 let matrix = &self.inverse_matrices[arm];
590 let vector = &self.b_vectors[arm];
591 if matrix.len() != self.feature_count
592 || matrix.iter().any(|row| row.len() != self.feature_count)
593 || vector.len() != self.feature_count
594 || matrix.iter().flatten().any(|value| !value.is_finite())
595 || vector.iter().any(|value| !value.is_finite())
596 {
597 return Err(RillError::InvalidState(format!(
598 "fast LinUCB arm {arm} has malformed dimensions or values"
599 )));
600 }
601 for (i, row) in matrix.iter().enumerate() {
602 for (j, &lower_value) in row.iter().take(i).enumerate() {
603 let upper_value = matrix[j][i];
604 let scale = lower_value.abs().max(upper_value.abs()).max(1.0);
605 if (lower_value - upper_value).abs() > 1e-12 * scale {
606 return Err(RillError::InvalidState(format!(
607 "fast LinUCB inverse for arm {arm} is not symmetric"
608 )));
609 }
610 }
611 }
612 cholesky_factor(matrix)?;
613 }
614 Ok(())
615 }
616
617 fn validate_context(&self, context: &[f64]) -> Result<(), RillError> {
618 if context.len() != self.feature_count {
619 return Err(RillError::DimensionMismatch {
620 expected: self.feature_count,
621 actual: context.len(),
622 });
623 }
624 for &value in context {
625 ensure_finite("context", value)?;
626 }
627 Ok(())
628 }
629
630 fn score_arm_validated(
631 &self,
632 arm: usize,
633 context: &[f64],
634 ) -> Result<LinUcbArmScore, RillError> {
635 let inverse = &self.inverse_matrices[arm];
636 let theta = checked_matrix_vector_mul(inverse, &self.b_vectors[arm])?;
637 let exploitation = checked_dot(&theta, context, "fast LinUCB exploitation")?;
638 let solved_context = checked_matrix_vector_mul(inverse, context)?;
639 let variance = checked_dot(context, &solved_context, "fast LinUCB variance")?;
640 let exploration_bonus = self.alpha * variance.max(0.0).sqrt();
641 ensure_finite("fast LinUCB exploration bonus", exploration_bonus)?;
642 let total_score =
643 checked_finite_add(exploitation, exploration_bonus, "fast LinUCB total score")?;
644 Ok(LinUcbArmScore {
645 arm,
646 exploitation,
647 exploration_bonus,
648 total_score,
649 })
650 }
651}
652
653impl ContextualBandit for LinUcbFast {
654 fn arm_count(&self) -> usize {
655 self.arm_count
656 }
657
658 fn feature_count(&self) -> usize {
659 self.feature_count
660 }
661
662 fn samples_seen(&self) -> u64 {
663 self.samples_seen
664 }
665
666 fn select(&self, context: &[f64], rng: &mut impl Rng) -> Result<usize, RillError> {
667 self.validate_context(context)?;
668 let mut best_arm = 0;
669 let mut best_score = f64::NEG_INFINITY;
670 let mut tied = 0;
671 for arm in 0..self.arm_count {
672 let score = self.score_arm_validated(arm, context)?.total_score;
673 if score > best_score {
674 best_score = score;
675 best_arm = arm;
676 tied = 1;
677 } else if score == best_score {
678 tied += 1;
679 if rng.gen_range(0..tied) == 0 {
680 best_arm = arm;
681 }
682 }
683 }
684 Ok(best_arm)
685 }
686
687 fn update(&mut self, arm: usize, context: &[f64], reward: f64) -> Result<(), RillError> {
688 validate_arm(self.arm_count, arm)?;
689 self.validate_context(context)?;
690 validate_reward_finite(reward)?;
691 let inverse = &self.inverse_matrices[arm];
692 let projected = checked_matrix_vector_mul(inverse, context)?;
693 let variance = checked_dot(context, &projected, "fast LinUCB update variance")?;
694 let variance_tolerance = 1e-12
695 * context
696 .iter()
697 .map(|value| value.abs())
698 .sum::<f64>()
699 .max(1.0);
700 if variance < -variance_tolerance {
701 return Err(RillError::InvalidState(
702 "fast LinUCB inverse produced a negative update variance".to_owned(),
703 ));
704 }
705 let denominator = checked_finite_add(1.0, variance, "Sherman-Morrison denominator")?;
706 if denominator <= f64::EPSILON {
707 return Err(RillError::InvalidState(
708 "fast LinUCB Sherman-Morrison denominator is not positive".to_owned(),
709 ));
710 }
711
712 let mut next_inverse = inverse.clone();
713 for i in 0..self.feature_count {
714 for j in 0..self.feature_count {
715 let adjustment = projected[i] * projected[j] / denominator;
716 next_inverse[i][j] = checked_finite_add(
717 next_inverse[i][j],
718 -adjustment,
719 "fast LinUCB inverse update",
720 )?;
721 }
722 }
723 for i in 0..self.feature_count {
725 let (previous_rows, current_and_later) = next_inverse.split_at_mut(i);
726 let current_row = &mut current_and_later[0];
727 for (j, previous_row) in previous_rows.iter().enumerate() {
728 current_row[j] = previous_row[i];
729 }
730 if current_row[i] <= 0.0 {
731 return Err(RillError::InvalidState(
732 "fast LinUCB inverse lost a positive diagonal".to_owned(),
733 ));
734 }
735 }
736
737 let mut next_b = self.b_vectors[arm].clone();
738 for i in 0..self.feature_count {
739 next_b[i] = checked_finite_add(next_b[i], reward * context[i], "fast LinUCB b vector")?;
740 }
741 let next_samples = checked_increment(self.samples_seen, "fast LinUCB samples_seen")?;
742 self.inverse_matrices[arm] = next_inverse;
743 self.b_vectors[arm] = next_b;
744 self.samples_seen = next_samples;
745 Ok(())
746 }
747
748 fn reset(&mut self) {
749 for matrix in &mut self.inverse_matrices {
750 *matrix = identity_matrix(self.feature_count);
751 }
752 for vector in &mut self.b_vectors {
753 vector.fill(0.0);
754 }
755 self.samples_seen = 0;
756 }
757}
758
759impl ValidateState for LinUcbFast {
760 fn validate_state(&self) -> Result<(), RillError> {
761 self.validate()
762 }
763}
764
765#[cfg(feature = "serde")]
766#[derive(serde::Deserialize)]
767struct LinUcbFastState {
768 arm_count: usize,
769 feature_count: usize,
770 alpha: f64,
771 inverse_matrices: Vec<Vec<Vec<f64>>>,
772 b_vectors: Vec<Vec<f64>>,
773 samples_seen: u64,
774}
775
776#[cfg(feature = "serde")]
777impl<'de> serde::Deserialize<'de> for LinUcbFast {
778 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
779 where
780 D: serde::Deserializer<'de>,
781 {
782 let state = LinUcbFastState::deserialize(deserializer)?;
783 let bandit = Self {
784 arm_count: state.arm_count,
785 feature_count: state.feature_count,
786 alpha: state.alpha,
787 inverse_matrices: state.inverse_matrices,
788 b_vectors: state.b_vectors,
789 samples_seen: state.samples_seen,
790 };
791 bandit.validate().map_err(serde::de::Error::custom)?;
792 Ok(bandit)
793 }
794}
795
796#[cfg(feature = "serde")]
797#[derive(serde::Deserialize)]
798struct LinUcbState {
799 arm_count: usize,
800 feature_count: usize,
801 alpha: f64,
802 a_matrices: Vec<Vec<Vec<f64>>>,
803 b_vectors: Vec<Vec<f64>>,
804 samples_seen: u64,
805}
806
807#[cfg(feature = "serde")]
808impl<'de> serde::Deserialize<'de> for LinUcb {
809 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
810 where
811 D: serde::Deserializer<'de>,
812 {
813 let state = LinUcbState::deserialize(deserializer)?;
814 let bandit = Self {
815 arm_count: state.arm_count,
816 feature_count: state.feature_count,
817 alpha: state.alpha,
818 a_matrices: state.a_matrices,
819 b_vectors: state.b_vectors,
820 samples_seen: state.samples_seen,
821 };
822 bandit.validate().map_err(serde::de::Error::custom)?;
823 Ok(bandit)
824 }
825}
826
827#[cfg(feature = "serde")]
828impl ValidateState for LinUcb {
829 fn validate_state(&self) -> Result<(), RillError> {
830 LinUcb::validate(self)
831 }
832}
833
834fn identity_matrix(d: usize) -> Vec<Vec<f64>> {
840 let mut m = vec![vec![0.0; d]; d];
841 for (i, row) in m.iter_mut().enumerate() {
842 row[i] = 1.0;
843 }
844 m
845}
846
847fn matrix_is_positive_definite(matrix: &[Vec<f64>]) -> bool {
849 cholesky_factor(matrix).is_ok()
850}
851
852fn cholesky_factor(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, RillError> {
854 let n = matrix.len();
855 if n == 0 || matrix.iter().any(|row| row.len() != n) {
856 return Err(RillError::InvalidState(
857 "LinUCB matrix must be non-empty and square".to_owned(),
858 ));
859 }
860 let mut lower = vec![vec![0.0; n]; n];
861 for i in 0..n {
862 for j in 0..=i {
863 let mut correction = 0.0;
864 for (&left, &right) in lower[i][..j].iter().zip(&lower[j][..j]) {
865 correction =
866 checked_finite_add(correction, left * right, "LinUCB Cholesky correction")?;
867 }
868 let residual = matrix[i][j] - correction;
869 if i == j {
870 if !residual.is_finite() || residual <= 0.0 {
871 return Err(RillError::InvalidState(
872 "LinUCB matrix is not positive definite".to_owned(),
873 ));
874 }
875 lower[i][j] = residual.sqrt();
876 } else {
877 lower[i][j] = residual / lower[j][j];
878 if !lower[i][j].is_finite() {
879 return Err(RillError::InvalidState(
880 "LinUCB Cholesky factor is non-finite".to_owned(),
881 ));
882 }
883 }
884 }
885 }
886 Ok(lower)
887}
888
889fn cholesky_solve(lower: &[Vec<f64>], rhs: &[f64]) -> Result<Vec<f64>, RillError> {
891 let n = lower.len();
892 if rhs.len() != n {
893 return Err(RillError::DimensionMismatch {
894 expected: n,
895 actual: rhs.len(),
896 });
897 }
898 let mut intermediate = vec![0.0; n];
899 for i in 0..n {
900 let mut correction = 0.0;
901 for (j, value) in intermediate.iter().enumerate().take(i) {
902 correction =
903 checked_finite_add(correction, lower[i][j] * value, "LinUCB forward solve")?;
904 }
905 let value = (rhs[i] - correction) / lower[i][i];
906 if !value.is_finite() {
907 return Err(RillError::NonFiniteValue {
908 field: "LinUCB forward solve",
909 value,
910 });
911 }
912 intermediate[i] = value;
913 }
914
915 let mut solution = vec![0.0; n];
916 for i in (0..n).rev() {
917 let mut correction = 0.0;
918 for j in (i + 1)..n {
919 correction = checked_finite_add(
920 correction,
921 lower[j][i] * solution[j],
922 "LinUCB backward solve",
923 )?;
924 }
925 let value = (intermediate[i] - correction) / lower[i][i];
926 if !value.is_finite() {
927 return Err(RillError::NonFiniteValue {
928 field: "LinUCB backward solve",
929 value,
930 });
931 }
932 solution[i] = value;
933 }
934 Ok(solution)
935}
936
937fn inverse_from_cholesky(lower: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, RillError> {
938 let n = lower.len();
939 let mut inverse = vec![vec![0.0; n]; n];
940 for column in 0..n {
941 let mut basis = vec![0.0; n];
942 basis[column] = 1.0;
943 let solution = cholesky_solve(lower, &basis)?;
944 for row in 0..n {
945 inverse[row][column] = solution[row];
946 }
947 }
948 for i in 0..n {
951 let (previous_rows, current_and_later) = inverse.split_at_mut(i);
952 let current_row = &mut current_and_later[0];
953 for (j, previous_row) in previous_rows.iter_mut().enumerate() {
954 let symmetric = (current_row[j] + previous_row[i]) / 2.0;
955 ensure_finite("LinUCB inverse", symmetric)?;
956 current_row[j] = symmetric;
957 previous_row[i] = symmetric;
958 }
959 }
960 Ok(inverse)
961}
962
963fn checked_matrix_vector_mul(matrix: &[Vec<f64>], vector: &[f64]) -> Result<Vec<f64>, RillError> {
964 let mut result = Vec::with_capacity(matrix.len());
965 for row in matrix {
966 result.push(checked_dot(row, vector, "LinUCB matrix-vector product")?);
967 }
968 Ok(result)
969}
970
971fn checked_dot(a: &[f64], b: &[f64], field: &'static str) -> Result<f64, RillError> {
972 let mut result = 0.0;
973 for (&left, &right) in a.iter().zip(b) {
974 result = checked_finite_add(result, left * right, field)?;
975 }
976 Ok(result)
977}
978
979fn select_random_tie(scores: &[LinUcbArmScore], rng: &mut impl Rng) -> usize {
980 let mut best_arm = scores[0].arm;
981 let mut best_score = scores[0].total_score;
982 let mut tied = 1usize;
983 for score in &scores[1..] {
984 if score.total_score > best_score {
985 best_score = score.total_score;
986 best_arm = score.arm;
987 tied = 1;
988 } else if score.total_score == best_score {
989 tied += 1;
990 if rng.gen_range(0..tied) == 0 {
991 best_arm = score.arm;
992 }
993 }
994 }
995 best_arm
996}
997
998#[allow(clippy::needless_range_loop)]
1004#[cfg(test)]
1005fn matrix_inverse(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, RillError> {
1006 let n = matrix.len();
1007 let mut aug = vec![vec![0.0; 2 * n]; n];
1009 for i in 0..n {
1010 for j in 0..n {
1011 aug[i][j] = matrix[i][j];
1012 }
1013 aug[i][n + i] = 1.0;
1014 }
1015
1016 for col in 0..n {
1018 let mut pivot = col;
1020 let mut max_val = aug[col][col].abs();
1021 for row in (col + 1)..n {
1022 if aug[row][col].abs() > max_val {
1023 max_val = aug[row][col].abs();
1024 pivot = row;
1025 }
1026 }
1027 if max_val < 1e-12 {
1028 return Err(RillError::InvalidParameter {
1029 name: "matrix",
1030 value: 0.0,
1031 });
1032 }
1033 if pivot != col {
1034 aug.swap(col, pivot);
1035 }
1036 let pivot_val = aug[col][col];
1038 for j in 0..(2 * n) {
1039 aug[col][j] /= pivot_val;
1040 }
1041 for row in 0..n {
1043 if row == col {
1044 continue;
1045 }
1046 let factor = aug[row][col];
1047 if factor == 0.0 {
1048 continue;
1049 }
1050 for j in 0..(2 * n) {
1051 aug[row][j] -= factor * aug[col][j];
1052 }
1053 }
1054 }
1055
1056 let mut inv = vec![vec![0.0; n]; n];
1058 for i in 0..n {
1059 for j in 0..n {
1060 inv[i][j] = aug[i][n + j];
1061 }
1062 }
1063 Ok(inv)
1064}
1065
1066#[cfg(test)]
1068fn matrix_vector_mul(matrix: &[Vec<f64>], vector: &[f64]) -> Vec<f64> {
1069 let n = matrix.len();
1070 let vector = &vector[..n];
1071 matrix
1072 .iter()
1073 .map(|row| {
1074 row[..n]
1075 .iter()
1076 .zip(vector)
1077 .map(|(matrix_value, vector_value)| matrix_value * vector_value)
1078 .sum()
1079 })
1080 .collect()
1081}
1082
1083#[cfg(test)]
1085fn dot(a: &[f64], b: &[f64]) -> f64 {
1086 a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
1087}
1088
1089#[cfg(test)]
1091fn quadratic_form(x: &[f64], matrix: &[Vec<f64>]) -> f64 {
1092 let n = x.len();
1093 let mut result = 0.0;
1094 for i in 0..n {
1095 for j in 0..n {
1096 result += x[i] * matrix[i][j] * x[j];
1097 }
1098 }
1099 result
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105 use rand::SeedableRng;
1106 use rand_chacha::ChaCha8Rng;
1107
1108 fn make_bandit() -> LinUcb {
1109 LinUcb::new(LinUcbConfig {
1110 alpha: 1.0,
1111 arm_count: 3,
1112 feature_count: 2,
1113 })
1114 .unwrap()
1115 }
1116
1117 #[test]
1118 fn rejects_zero_arm_count() {
1119 let result = LinUcb::new(LinUcbConfig {
1120 alpha: 1.0,
1121 arm_count: 0,
1122 feature_count: 2,
1123 });
1124 assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
1125 }
1126
1127 #[test]
1128 fn rejects_zero_feature_count() {
1129 let result = LinUcb::new(LinUcbConfig {
1130 alpha: 1.0,
1131 arm_count: 3,
1132 feature_count: 0,
1133 });
1134 assert!(matches!(result, Err(RillError::InvalidFeatureCount(0))));
1135 }
1136
1137 #[test]
1138 fn rejects_invalid_alpha() {
1139 for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
1140 let result = LinUcb::new(LinUcbConfig {
1141 alpha: bad,
1142 arm_count: 3,
1143 feature_count: 2,
1144 });
1145 assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
1146 }
1147 }
1148
1149 #[test]
1150 fn initial_state() {
1151 let b = make_bandit();
1152 assert_eq!(b.arm_count(), 3);
1153 assert_eq!(b.feature_count(), 2);
1154 assert_eq!(b.samples_seen(), 0);
1155 assert!((b.alpha() - 1.0).abs() < 1e-12);
1156 }
1157
1158 #[test]
1159 fn initial_a_is_identity() {
1160 let b = make_bandit();
1161 let a = b.a_matrix(0).unwrap();
1162 assert!((a[0][0] - 1.0).abs() < 1e-12);
1163 assert!((a[0][1] - 0.0).abs() < 1e-12);
1164 assert!((a[1][0] - 0.0).abs() < 1e-12);
1165 assert!((a[1][1] - 1.0).abs() < 1e-12);
1166 }
1167
1168 #[test]
1169 fn initial_b_is_zero() {
1170 let b = make_bandit();
1171 let bv = b.b_vector(0).unwrap();
1172 assert!((bv[0] - 0.0).abs() < 1e-12);
1173 assert!((bv[1] - 0.0).abs() < 1e-12);
1174 }
1175
1176 #[test]
1177 fn initial_ties_are_randomized() {
1178 let b = make_bandit();
1179 let mut rng = ChaCha8Rng::seed_from_u64(12);
1180 let mut seen = std::collections::HashSet::new();
1181 for _ in 0..100 {
1182 seen.insert(b.select(&[0.5, 0.8], &mut rng).unwrap());
1183 }
1184 assert_eq!(seen.len(), b.arm_count());
1185 }
1186
1187 #[test]
1188 fn score_breakdown_is_finite_and_sums_to_total() {
1189 let b = make_bandit();
1190 let scores = b.score_all(&[0.5, 0.8]).unwrap();
1191 assert_eq!(scores.len(), 3);
1192 for (arm, score) in scores.iter().enumerate() {
1193 assert_eq!(score.arm, arm);
1194 assert!(score.exploitation.is_finite());
1195 assert!(score.exploration_bonus.is_finite());
1196 assert!(score.total_score.is_finite());
1197 assert_eq!(
1198 score.total_score,
1199 score.exploitation + score.exploration_bonus
1200 );
1201 assert_eq!(score.exploitation, 0.0);
1202 assert!((score.exploration_bonus - (0.5_f64 * 0.5 + 0.8 * 0.8).sqrt()).abs() < 1e-12);
1203 }
1204 }
1205
1206 #[test]
1207 fn score_arm_validates_arm_context_and_dimension() {
1208 let b = make_bandit();
1209 assert!(matches!(
1210 b.score_arm(3, &[0.5, 0.8]),
1211 Err(RillError::InvalidArm { .. })
1212 ));
1213 assert!(matches!(
1214 b.score_arm(0, &[0.5]),
1215 Err(RillError::DimensionMismatch { .. })
1216 ));
1217 assert!(matches!(
1218 b.score_arm(0, &[f64::NAN, 0.8]),
1219 Err(RillError::NonFiniteValue { .. })
1220 ));
1221 }
1222
1223 #[test]
1224 fn select_with_scores_matches_select_including_random_ties() {
1225 let b = make_bandit();
1226 for seed in 0..100 {
1227 let mut select_rng = ChaCha8Rng::seed_from_u64(seed);
1228 let mut scores_rng = ChaCha8Rng::seed_from_u64(seed);
1229 let selected = b.select(&[0.5, 0.8], &mut select_rng).unwrap();
1230 let (explained, scores) = b.select_with_scores(&[0.5, 0.8], &mut scores_rng).unwrap();
1231 assert_eq!(explained, selected);
1232 assert_eq!(scores.len(), 3);
1233 }
1234 }
1235
1236 #[test]
1237 fn deterministic_selection_uses_lowest_index_for_exact_ties() {
1238 let b = make_bandit();
1239 assert_eq!(b.select_deterministic(&[0.5, 0.8]).unwrap(), 0);
1240 }
1241
1242 #[test]
1243 fn single_arm_scoring_and_selection() {
1244 let b = LinUcb::new(LinUcbConfig {
1245 alpha: 0.5,
1246 arm_count: 1,
1247 feature_count: 2,
1248 })
1249 .unwrap();
1250 let score = b.score_arm(0, &[3.0, 4.0]).unwrap();
1251 assert_eq!(score.arm, 0);
1252 assert_eq!(score.exploitation, 0.0);
1253 assert!((score.exploration_bonus - 2.5).abs() < 1e-12);
1254 assert_eq!(b.select_deterministic(&[3.0, 4.0]).unwrap(), 0);
1255 }
1256
1257 #[test]
1258 fn select_returns_valid_arm() {
1259 let b = make_bandit();
1260 let mut rng = ChaCha8Rng::seed_from_u64(42);
1261 let context = [0.5, 0.8];
1262 let arm = b.select(&context, &mut rng).unwrap();
1263 assert!(arm < 3);
1264 }
1265
1266 #[test]
1267 fn update_modifies_a_and_b() {
1268 let mut b = make_bandit();
1269 let context = [0.5, 0.8];
1270 b.update(0, &context, 1.0).unwrap();
1271
1272 let a = b.a_matrix(0).unwrap();
1273 assert!((a[0][0] - (1.0 + 0.5 * 0.5)).abs() < 1e-12);
1275 assert!((a[0][1] - (0.5 * 0.8)).abs() < 1e-12);
1276 assert!((a[1][0] - (0.8 * 0.5)).abs() < 1e-12);
1277 assert!((a[1][1] - (1.0 + 0.8 * 0.8)).abs() < 1e-12);
1278
1279 let bv = b.b_vector(0).unwrap();
1280 assert!((bv[0] - 0.5).abs() < 1e-12);
1281 assert!((bv[1] - 0.8).abs() < 1e-12);
1282 assert_eq!(b.samples_seen(), 1);
1283 }
1284
1285 #[test]
1286 fn update_does_not_affect_other_arms() {
1287 let mut b = make_bandit();
1288 let context = [0.5, 0.8];
1289 b.update(0, &context, 1.0).unwrap();
1290
1291 let a1 = b.a_matrix(1).unwrap();
1293 assert!((a1[0][0] - 1.0).abs() < 1e-12);
1294 let b1 = b.b_vector(1).unwrap();
1295 assert!((b1[0] - 0.0).abs() < 1e-12);
1296 }
1297
1298 #[test]
1299 fn select_rejects_wrong_context_length() {
1300 let b = make_bandit();
1301 let mut rng = ChaCha8Rng::seed_from_u64(0);
1302 assert!(b.select(&[0.5], &mut rng).is_err());
1303 assert!(b.select(&[0.5, 0.8, 0.9], &mut rng).is_err());
1304 }
1305
1306 #[test]
1307 fn select_rejects_non_finite_context() {
1308 let b = make_bandit();
1309 let mut rng = ChaCha8Rng::seed_from_u64(0);
1310 assert!(b.select(&[f64::NAN, 0.8], &mut rng).is_err());
1311 assert!(b.select(&[0.5, f64::INFINITY], &mut rng).is_err());
1312 }
1313
1314 #[test]
1315 fn update_rejects_invalid_arm() {
1316 let mut b = make_bandit();
1317 let context = [0.5, 0.8];
1318 assert!(b.update(3, &context, 1.0).is_err());
1319 }
1320
1321 #[test]
1322 fn update_rejects_wrong_context_length() {
1323 let mut b = make_bandit();
1324 assert!(b.update(0, &[0.5], 1.0).is_err());
1325 assert!(b.update(0, &[0.5, 0.8, 0.9], 1.0).is_err());
1326 }
1327
1328 #[test]
1329 fn update_rejects_non_finite_reward() {
1330 let mut b = make_bandit();
1331 let context = [0.5, 0.8];
1332 assert!(b.update(0, &context, f64::NAN).is_err());
1333 assert!(b.update(0, &context, f64::INFINITY).is_err());
1334 }
1335
1336 #[test]
1337 fn update_rejects_arithmetic_overflow_without_mutating_state() {
1338 let mut b = make_bandit();
1339 let before = b.clone();
1340 assert!(b.update(0, &[f64::MAX, f64::MAX], 1.0).is_err());
1341 assert_eq!(b.a_matrices, before.a_matrices);
1342 assert_eq!(b.b_vectors, before.b_vectors);
1343 assert_eq!(b.samples_seen(), before.samples_seen());
1344 }
1345
1346 #[test]
1347 fn update_rejects_numerically_degenerate_matrix_without_mutating_state() {
1348 let mut b = make_bandit();
1349 let before = b.clone();
1350 let result = b.update(0, &[1e150, 1e150], 1.0);
1351 assert!(matches!(result, Err(RillError::InvalidState(_))));
1352 assert_eq!(b.a_matrices, before.a_matrices);
1353 assert_eq!(b.b_vectors, before.b_vectors);
1354 assert_eq!(b.samples_seen(), before.samples_seen());
1355 }
1356
1357 #[test]
1358 fn scoring_is_side_effect_free() {
1359 let mut b = make_bandit();
1360 b.update(1, &[0.25, -0.75], 2.0).unwrap();
1361 let before = b.clone();
1362 let _ = b.score_all(&[0.75, 0.5]).unwrap();
1363 let _ = b.select_deterministic(&[0.75, 0.5]).unwrap();
1364 assert_eq!(b.a_matrices, before.a_matrices);
1365 assert_eq!(b.b_vectors, before.b_vectors);
1366 assert_eq!(b.samples_seen, before.samples_seen);
1367 }
1368
1369 #[test]
1370 fn cholesky_scores_match_explicit_inverse_reference() {
1371 let mut b = make_bandit();
1372 for i in 1..=50 {
1373 let context = [i as f64 / 17.0, (i as f64).sin()];
1374 b.update(i % 3, &context, (i as f64 / 7.0).cos()).unwrap();
1375 }
1376 let context = [0.75, -1.25];
1377 for arm in 0..3 {
1378 let inverse = matrix_inverse(&b.a_matrices[arm]).unwrap();
1379 let theta = matrix_vector_mul(&inverse, &b.b_vectors[arm]);
1380 let expected_exploitation = dot(&theta, &context);
1381 let expected_bonus = b.alpha * quadratic_form(&context, &inverse).sqrt();
1382 let score = b.score_arm(arm, &context).unwrap();
1383 assert!((score.exploitation - expected_exploitation).abs() < 1e-10);
1384 assert!((score.exploration_bonus - expected_bonus).abs() < 1e-10);
1385 }
1386 }
1387
1388 #[test]
1389 fn condition_diagnostics_are_finite() {
1390 let mut b = make_bandit();
1391 for _ in 0..1000 {
1392 b.update(0, &[1e-6, 1e3], 0.25).unwrap();
1393 }
1394 let diagnostics = b.condition_diagnostics(0).unwrap();
1395 assert_eq!(diagnostics.arm, 0);
1396 assert!(diagnostics.min_cholesky_diagonal > 0.0);
1397 assert!(diagnostics.max_cholesky_diagonal.is_finite());
1398 assert!(diagnostics.condition_indicator.is_finite());
1399 assert!(diagnostics.condition_indicator >= 1.0);
1400 }
1401
1402 #[test]
1403 fn a_matrix_rejects_invalid_arm() {
1404 let b = make_bandit();
1405 assert!(b.a_matrix(5).is_err());
1406 }
1407
1408 #[test]
1409 fn b_vector_rejects_invalid_arm() {
1410 let b = make_bandit();
1411 assert!(b.b_vector(5).is_err());
1412 }
1413
1414 #[test]
1415 fn reset_clears_state() {
1416 let mut b = make_bandit();
1417 let context = [0.5, 0.8];
1418 b.update(0, &context, 1.0).unwrap();
1419 b.update(1, &context, 0.5).unwrap();
1420 assert_eq!(b.samples_seen(), 2);
1421
1422 b.reset();
1423 assert_eq!(b.samples_seen(), 0);
1424 let a = b.a_matrix(0).unwrap();
1426 assert!((a[0][0] - 1.0).abs() < 1e-12);
1427 let bv = b.b_vector(0).unwrap();
1429 assert!((bv[0] - 0.0).abs() < 1e-12);
1430 }
1431
1432 #[test]
1433 fn identity_matrix_inverse_is_identity() {
1434 let ident = identity_matrix(3);
1435 let inv = matrix_inverse(&ident).unwrap();
1436 for (i, row) in inv.iter().enumerate() {
1437 for (j, &val) in row.iter().enumerate() {
1438 let expected = if i == j { 1.0 } else { 0.0 };
1439 assert!((val - expected).abs() < 1e-12);
1440 }
1441 }
1442 }
1443
1444 #[test]
1445 fn known_2x2_matrix_inverse() {
1446 let matrix = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
1448 let inv = matrix_inverse(&matrix).unwrap();
1449 assert!((inv[0][0] - 0.6).abs() < 1e-10);
1450 assert!((inv[0][1] - (-0.7)).abs() < 1e-10);
1451 assert!((inv[1][0] - (-0.2)).abs() < 1e-10);
1452 assert!((inv[1][1] - 0.4).abs() < 1e-10);
1453 }
1454
1455 #[test]
1456 fn singular_matrix_inverse_returns_error() {
1457 let matrix = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
1459 let result = matrix_inverse(&matrix);
1460 assert!(result.is_err());
1461 }
1462
1463 #[test]
1464 fn dot_product_correct() {
1465 assert!((dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]) - 32.0).abs() < 1e-12);
1466 }
1467
1468 #[test]
1469 fn matrix_vector_mul_correct() {
1470 let m = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
1471 let v = vec![5.0, 6.0];
1472 let r = matrix_vector_mul(&m, &v);
1473 assert!((r[0] - 17.0).abs() < 1e-12);
1474 assert!((r[1] - 39.0).abs() < 1e-12);
1475 }
1476
1477 #[test]
1478 fn quadratic_form_correct() {
1479 let ident = identity_matrix(3);
1481 let x = [1.0, 2.0, 3.0];
1482 let q = quadratic_form(&x, &ident);
1483 assert!((q - 14.0).abs() < 1e-12);
1484 }
1485
1486 #[test]
1487 fn contextual_selection_prefers_aligned_arm() {
1488 let mut b = LinUcb::new(LinUcbConfig {
1493 alpha: 0.1,
1494 arm_count: 2,
1495 feature_count: 2,
1496 })
1497 .unwrap();
1498 let mut rng = ChaCha8Rng::seed_from_u64(7);
1499
1500 for _ in 0..20 {
1502 b.update(0, &[1.0, 0.0], 1.0).unwrap();
1503 }
1504 for _ in 0..20 {
1506 b.update(1, &[0.0, 1.0], 1.0).unwrap();
1507 }
1508
1509 let arm = b.select(&[1.0, 0.0], &mut rng).unwrap();
1511 assert_eq!(arm, 0);
1512
1513 let arm = b.select(&[0.0, 1.0], &mut rng).unwrap();
1515 assert_eq!(arm, 1);
1516 }
1517
1518 #[test]
1519 fn learns_to_prefer_high_reward_arm() {
1520 let mut b = LinUcb::new(LinUcbConfig {
1523 alpha: 0.5,
1524 arm_count: 2,
1525 feature_count: 1,
1526 })
1527 .unwrap();
1528 let mut rng = ChaCha8Rng::seed_from_u64(99);
1529
1530 for step in 1..=100 {
1531 let x = step as f64 * 0.1;
1532 let arm = b.select(&[x], &mut rng).unwrap();
1533 let reward = if arm == 0 { 2.0 * x } else { 0.1 * x };
1535 b.update(arm, &[x], reward).unwrap();
1536 }
1537
1538 let final_arm = b.select(&[5.0], &mut rng).unwrap();
1540 assert_eq!(final_arm, 0);
1541 }
1542
1543 #[test]
1544 fn fast_linucb_matches_stable_scores_and_selection() {
1545 let config = LinUcbConfig {
1546 alpha: 0.35,
1547 arm_count: 4,
1548 feature_count: 6,
1549 };
1550 let mut stable = LinUcb::new(config.clone()).unwrap();
1551 let mut fast = LinUcbFast::new(config).unwrap();
1552 for step in 0..2000 {
1553 let context: Vec<f64> = (0..6)
1554 .map(|feature| ((step + feature * 17) as f64 / 23.0).sin())
1555 .collect();
1556 let arm = step % 4;
1557 let reward = (arm as f64 + 1.0) * context[arm % 6] + 0.1;
1558 stable.update(arm, &context, reward).unwrap();
1559 fast.update(arm, &context, reward).unwrap();
1560 }
1561 let context = [0.75, -0.25, 1.0, 0.1, -0.5, 0.9];
1562 let stable_scores = stable.score_all(&context).unwrap();
1563 let fast_scores = fast.score_all(&context).unwrap();
1564 for (stable_score, fast_score) in stable_scores.iter().zip(&fast_scores) {
1565 assert_eq!(stable_score.arm, fast_score.arm);
1566 assert!((stable_score.exploitation - fast_score.exploitation).abs() < 1e-8);
1567 assert!((stable_score.exploration_bonus - fast_score.exploration_bonus).abs() < 1e-8);
1568 assert!((stable_score.total_score - fast_score.total_score).abs() < 1e-8);
1569 }
1570 assert_eq!(
1571 stable.select_deterministic(&context).unwrap(),
1572 fast.select_deterministic(&context).unwrap()
1573 );
1574 assert_eq!(stable.samples_seen(), fast.samples_seen());
1575 fast.validate().unwrap();
1576 }
1577
1578 #[test]
1579 fn fast_conversion_matches_stable_state() {
1580 let mut stable = make_bandit();
1581 for step in 0..100 {
1582 let context = [(step as f64 / 11.0).sin(), (step as f64 / 7.0).cos()];
1583 stable
1584 .update(step % 3, &context, step as f64 / 100.0)
1585 .unwrap();
1586 }
1587 let fast = LinUcbFast::from_linucb(&stable).unwrap();
1588 let context = [0.25, -0.75];
1589 for arm in 0..3 {
1590 let stable_score = stable.score_arm(arm, &context).unwrap();
1591 let fast_score = fast.score_arm(arm, &context).unwrap();
1592 assert!((stable_score.total_score - fast_score.total_score).abs() < 1e-10);
1593 }
1594 assert_eq!(fast.state_f64_count(), 3 * (2 * 2 + 2) + 1);
1595 }
1596
1597 #[test]
1598 fn fast_update_failure_is_atomic() {
1599 let mut fast = LinUcbFast::new(LinUcbConfig {
1600 alpha: 1.0,
1601 arm_count: 2,
1602 feature_count: 2,
1603 })
1604 .unwrap();
1605 let before = fast.clone();
1606 assert!(fast.update(0, &[1e200, 1e200], 1.0).is_err());
1607 assert_eq!(fast.inverse_matrices, before.inverse_matrices);
1608 assert_eq!(fast.b_vectors, before.b_vectors);
1609 assert_eq!(fast.samples_seen, before.samples_seen);
1610 }
1611
1612 #[cfg(feature = "serde")]
1613 #[test]
1614 fn fast_serde_roundtrip_preserves_future_continuity() {
1615 let mut original = LinUcbFast::new(LinUcbConfig {
1616 alpha: 0.5,
1617 arm_count: 2,
1618 feature_count: 3,
1619 })
1620 .unwrap();
1621 original.update(0, &[1.0, 0.5, -0.5], 1.0).unwrap();
1622 let json = serde_json::to_string(&original).unwrap();
1623 let mut restored: LinUcbFast = serde_json::from_str(&json).unwrap();
1624 for step in 0..100 {
1625 let context = [step as f64 / 100.0, 0.25, -0.75];
1626 original.update(step % 2, &context, 0.5).unwrap();
1627 restored.update(step % 2, &context, 0.5).unwrap();
1628 assert_eq!(
1629 original.score_all(&context).unwrap(),
1630 restored.score_all(&context).unwrap()
1631 );
1632 }
1633 }
1634
1635 #[cfg(feature = "serde")]
1636 #[test]
1637 fn serde_roundtrip() {
1638 let mut b = LinUcb::new(LinUcbConfig {
1639 alpha: 1.5,
1640 arm_count: 2,
1641 feature_count: 3,
1642 })
1643 .unwrap();
1644 b.update(0, &[1.0, 0.5, 0.2], 1.0).unwrap();
1645 b.update(1, &[0.3, 0.7, 0.9], 0.5).unwrap();
1646
1647 let json = serde_json::to_string(&b).unwrap();
1648 let restored: LinUcb = serde_json::from_str(&json).unwrap();
1649 assert_eq!(restored.arm_count(), b.arm_count());
1650 assert_eq!(restored.feature_count(), b.feature_count());
1651 assert_eq!(restored.samples_seen(), b.samples_seen());
1652 assert!((restored.alpha() - b.alpha()).abs() < 1e-12);
1653 let orig_a = b.a_matrix(0).unwrap();
1655 let rest_a = restored.a_matrix(0).unwrap();
1656 for (orig_row, rest_row) in orig_a.iter().zip(rest_a.iter()) {
1657 for (&o, &r) in orig_row.iter().zip(rest_row.iter()) {
1658 assert!((o - r).abs() < 1e-12);
1659 }
1660 }
1661 assert_eq!(
1662 restored.score_all(&[0.2, -0.5, 1.0]).unwrap(),
1663 b.score_all(&[0.2, -0.5, 1.0]).unwrap()
1664 );
1665 }
1666
1667 #[cfg(feature = "serde")]
1668 #[test]
1669 fn serde_rejects_malformed_state() {
1670 let json = r#"{
1671 "arm_count": 2,
1672 "feature_count": 2,
1673 "alpha": 1.0,
1674 "a_matrices": [[[1.0, 0.0], [0.0, 1.0]]],
1675 "b_vectors": [[0.0, 0.0]],
1676 "samples_seen": 0
1677 }"#;
1678 assert!(serde_json::from_str::<LinUcb>(json).is_err());
1679 }
1680}