Skip to main content

rill_ml/bandit/
linucb.rs

1//! LinUCB contextual bandit algorithm.
2//!
3//! LinUCB extends the multi-armed bandit to contextual settings by maintaining
4//! a linear model for each arm. For a context vector `x`, each arm `a` is
5//! scored by:
6//!
7//! ```text
8//! p_a = theta_a^T * x + alpha * sqrt(x^T * A_a^{-1} * x)
9//! ```
10//!
11//! where `A_a` is the `d x d` matrix `I + sum(x_t * x_t^T)` over observed
12//! updates for arm `a`, `b_a` is the `d` vector `sum(reward_t * x_t)`, and
13//! `theta_a = A_a^{-1} * b_a`. The first term exploits the arm's linear model;
14//! the second term is an exploration bonus that is large for under-explored
15//! arms (in directions where `A_a^{-1}` is still big).
16//!
17//! On `update`, `A_a += x * x^T` and `b_a += reward * x`.
18//!
19//! ## Complexity
20//!
21//! - `select`: `O(arm_count * d^3)` — a matrix inversion per arm (cached
22//!   internally per call). For small `d` (typical: `d <= 32`) this is
23//!   negligible.
24//! - `update`: `O(d^2)` for the outer-product accumulation on the selected arm
25//!   (other arms are untouched).
26//! - Space: `O(arm_count * d^2)`.
27//!
28//! ## Reference
29//!
30//! Li, Chu, Langford, Schapire. "A Contextual-Bandit Approach to Personalized
31//! News Article Recommendation." WWW 2010.
32
33use crate::bandit::{
34    ContextualBandit, checked_finite_add, checked_increment, validate_arm, validate_reward_finite,
35};
36use crate::error::RillError;
37#[cfg(feature = "serde")]
38use crate::persistence::ValidateState;
39use rand::Rng;
40
41/// Configuration for [`LinUcb`].
42///
43/// # Examples
44///
45/// ```
46/// use rill_ml::bandit::LinUcbConfig;
47///
48/// let mut config = LinUcbConfig::default();
49/// config.alpha = 1.0;
50/// config.arm_count = 3;
51/// config.feature_count = 2;
52/// assert_eq!(config.arm_count, 3);
53/// ```
54#[derive(Debug, Clone, PartialEq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[non_exhaustive]
57pub struct LinUcbConfig {
58    /// Exploration parameter `alpha`. Controls the exploration/exploitation
59    /// trade-off. Higher values favor exploration. Must be finite and positive.
60    ///
61    /// The original paper suggests `alpha = 1.0` as a reasonable default.
62    pub alpha: f64,
63    /// Number of arms (actions). Must be greater than zero.
64    pub arm_count: usize,
65    /// Number of features in the context vector. Must be greater than zero.
66    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    /// Validate the configuration without constructing a bandit.
81    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/// LinUCB contextual multi-armed bandit.
99///
100/// Maintains a per-arm ridge-regression model and selects the arm with the
101/// highest upper confidence bound on the expected reward for the given
102/// context.
103///
104/// # Examples
105///
106/// ```
107/// use rill_ml::bandit::{ContextualBandit, LinUcb, LinUcbConfig};
108/// use rand::SeedableRng;
109/// use rand_chacha::ChaCha8Rng;
110///
111/// let mut config = LinUcbConfig::default();
112/// config.alpha = 1.0;
113/// config.arm_count = 2;
114/// config.feature_count = 2;
115/// let mut bandit = LinUcb::new(config).unwrap();
116/// let mut rng = ChaCha8Rng::seed_from_u64(0);
117///
118/// let context = [0.5, 0.8];
119/// let arm = bandit.select(&context, &mut rng).unwrap();
120/// bandit.update(arm, &context, 1.0).unwrap();
121/// assert_eq!(bandit.samples_seen(), 1);
122/// ```
123#[derive(Debug, Clone)]
124#[cfg_attr(feature = "serde", derive(serde::Serialize))]
125pub struct LinUcb {
126    arm_count: usize,
127    feature_count: usize,
128    alpha: f64,
129    /// Per-arm `d x d` matrices `A_a`, initialized to the identity matrix.
130    a_matrices: Vec<Vec<Vec<f64>>>,
131    /// Per-arm `d` vectors `b_a`, initialized to zero.
132    b_vectors: Vec<Vec<f64>>,
133    /// Total number of updates.
134    samples_seen: u64,
135}
136
137impl LinUcb {
138    /// Create a new LinUCB bandit from the given configuration.
139    ///
140    /// # Errors
141    ///
142    /// Returns `RillError::InvalidArmCount` if `arm_count` is zero.
143    /// Returns `RillError::InvalidFeatureCount` if `feature_count` is zero.
144    /// Returns `RillError::InvalidParameter` if `alpha` is not finite and
145    /// positive.
146    pub fn new(config: LinUcbConfig) -> Result<Self, RillError> {
147        config.validate()?;
148
149        let d = config.feature_count;
150        let a_matrices = (0..config.arm_count).map(|_| identity_matrix(d)).collect();
151        let b_vectors = (0..config.arm_count).map(|_| vec![0.0; d]).collect();
152
153        Ok(Self {
154            arm_count: config.arm_count,
155            feature_count: config.feature_count,
156            alpha: config.alpha,
157            a_matrices,
158            b_vectors,
159            samples_seen: 0,
160        })
161    }
162
163    /// The exploration parameter `alpha`.
164    pub const fn alpha(&self) -> f64 {
165        self.alpha
166    }
167
168    /// Borrow the `A` matrix for a specific arm (diagnostic).
169    ///
170    /// # Errors
171    ///
172    /// Returns `RillError::InvalidArm` if `arm` is out of range.
173    pub fn a_matrix(&self, arm: usize) -> Result<&[Vec<f64>], RillError> {
174        validate_arm(self.arm_count, arm)?;
175        Ok(&self.a_matrices[arm])
176    }
177
178    /// Borrow the `b` vector for a specific arm (diagnostic).
179    ///
180    /// # Errors
181    ///
182    /// Returns `RillError::InvalidArm` if `arm` is out of range.
183    pub fn b_vector(&self, arm: usize) -> Result<&[f64], RillError> {
184        validate_arm(self.arm_count, arm)?;
185        Ok(&self.b_vectors[arm])
186    }
187
188    /// Validate all persisted state invariants.
189    ///
190    /// This is also run automatically during deserialization.
191    pub fn validate(&self) -> Result<(), RillError> {
192        LinUcbConfig {
193            alpha: self.alpha,
194            arm_count: self.arm_count,
195            feature_count: self.feature_count,
196        }
197        .validate()?;
198        if self.a_matrices.len() != self.arm_count || self.b_vectors.len() != self.arm_count {
199            return Err(RillError::InvalidState(
200                "arm_count does not match per-arm state lengths".to_owned(),
201            ));
202        }
203
204        for arm in 0..self.arm_count {
205            let matrix = &self.a_matrices[arm];
206            let vector = &self.b_vectors[arm];
207            if matrix.len() != self.feature_count
208                || matrix.iter().any(|row| row.len() != self.feature_count)
209                || vector.len() != self.feature_count
210            {
211                return Err(RillError::InvalidState(format!(
212                    "arm {arm} state does not match feature_count"
213                )));
214            }
215            if matrix.iter().flatten().any(|value| !value.is_finite())
216                || vector.iter().any(|value| !value.is_finite())
217            {
218                return Err(RillError::InvalidState(format!(
219                    "arm {arm} state contains a non-finite value"
220                )));
221            }
222            for (i, row) in matrix.iter().enumerate() {
223                for (j, &value) in row.iter().take(i).enumerate() {
224                    if value != matrix[j][i] {
225                        return Err(RillError::InvalidState(format!(
226                            "A matrix for arm {arm} is not symmetric"
227                        )));
228                    }
229                }
230            }
231            if !matrix_is_positive_definite(matrix) {
232                return Err(RillError::InvalidState(format!(
233                    "A matrix for arm {arm} is not positive definite"
234                )));
235            }
236        }
237        Ok(())
238    }
239
240    /// Validate that the context vector has the expected length and is finite.
241    fn validate_context(&self, context: &[f64]) -> Result<(), RillError> {
242        if context.len() != self.feature_count {
243            return Err(RillError::DimensionMismatch {
244                expected: self.feature_count,
245                actual: context.len(),
246            });
247        }
248        for (i, &v) in context.iter().enumerate() {
249            if !v.is_finite() {
250                return Err(RillError::NonFiniteValue {
251                    field: "context",
252                    value: context[i],
253                });
254            }
255        }
256        Ok(())
257    }
258
259    /// Compute the UCB score for a single arm given the context.
260    ///
261    /// Returns `(theta_dot_x, exploration_bonus)` where the score is
262    /// `theta_dot_x + alpha * sqrt(exploration_bonus)`.
263    fn arm_score(&self, arm: usize, context: &[f64]) -> Result<f64, RillError> {
264        let a_inv = matrix_inverse(&self.a_matrices[arm])?;
265        let b = &self.b_vectors[arm];
266        // theta = A^{-1} * b
267        let theta = matrix_vector_mul(&a_inv, b);
268        // theta^T * x
269        let exploitation = dot(&theta, context);
270        // x^T * A^{-1} * x
271        let quad = quadratic_form(context, &a_inv);
272        // Numerical safety: the quadratic form should be non-negative for a
273        // positive-definite A, but rounding can make it slightly negative.
274        let quad_safe = if quad < 0.0 { 0.0 } else { quad };
275        let score = exploitation + self.alpha * quad_safe.sqrt();
276        if !score.is_finite() {
277            return Err(RillError::NonFiniteValue {
278                field: "LinUCB score",
279                value: score,
280            });
281        }
282        Ok(score)
283    }
284}
285
286impl ContextualBandit for LinUcb {
287    fn arm_count(&self) -> usize {
288        self.arm_count
289    }
290
291    fn feature_count(&self) -> usize {
292        self.feature_count
293    }
294
295    fn samples_seen(&self) -> u64 {
296        self.samples_seen
297    }
298
299    fn select(&self, context: &[f64], rng: &mut impl Rng) -> Result<usize, RillError> {
300        self.validate_context(context)?;
301
302        let mut best_arm = 0usize;
303        let mut best_score = f64::NEG_INFINITY;
304        let mut tied = 0usize;
305        for arm in 0..self.arm_count {
306            let score = self.arm_score(arm, context)?;
307            if score > best_score {
308                best_score = score;
309                best_arm = arm;
310                tied = 1;
311            } else if score == best_score {
312                // Reservoir sampling avoids a permanent low-index bias while
313                // keeping selection allocation-free.
314                tied += 1;
315                if rng.gen_range(0..tied) == 0 {
316                    best_arm = arm;
317                }
318            }
319        }
320        Ok(best_arm)
321    }
322
323    fn update(&mut self, arm: usize, context: &[f64], reward: f64) -> Result<(), RillError> {
324        validate_arm(self.arm_count, arm)?;
325        self.validate_context(context)?;
326        validate_reward_finite(reward)?;
327
328        let d = self.feature_count;
329        let mut next_a = self.a_matrices[arm].clone();
330        for i in 0..d {
331            for j in 0..d {
332                next_a[i][j] =
333                    checked_finite_add(next_a[i][j], context[i] * context[j], "A matrix")?;
334            }
335        }
336        let mut next_b = self.b_vectors[arm].clone();
337        for i in 0..d {
338            next_b[i] = checked_finite_add(next_b[i], reward * context[i], "b vector")?;
339        }
340        let next_samples = checked_increment(self.samples_seen, "samples_seen")?;
341
342        self.a_matrices[arm] = next_a;
343        self.b_vectors[arm] = next_b;
344        self.samples_seen = next_samples;
345        Ok(())
346    }
347
348    fn reset(&mut self) {
349        for a in &mut self.a_matrices {
350            *a = identity_matrix(self.feature_count);
351        }
352        for b in &mut self.b_vectors {
353            for v in b.iter_mut() {
354                *v = 0.0;
355            }
356        }
357        self.samples_seen = 0;
358    }
359}
360
361#[cfg(feature = "serde")]
362#[derive(serde::Deserialize)]
363struct LinUcbState {
364    arm_count: usize,
365    feature_count: usize,
366    alpha: f64,
367    a_matrices: Vec<Vec<Vec<f64>>>,
368    b_vectors: Vec<Vec<f64>>,
369    samples_seen: u64,
370}
371
372#[cfg(feature = "serde")]
373impl<'de> serde::Deserialize<'de> for LinUcb {
374    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
375    where
376        D: serde::Deserializer<'de>,
377    {
378        let state = LinUcbState::deserialize(deserializer)?;
379        let bandit = Self {
380            arm_count: state.arm_count,
381            feature_count: state.feature_count,
382            alpha: state.alpha,
383            a_matrices: state.a_matrices,
384            b_vectors: state.b_vectors,
385            samples_seen: state.samples_seen,
386        };
387        bandit.validate().map_err(serde::de::Error::custom)?;
388        Ok(bandit)
389    }
390}
391
392#[cfg(feature = "serde")]
393impl ValidateState for LinUcb {
394    fn validate_state(&self) -> Result<(), RillError> {
395        LinUcb::validate(self)
396    }
397}
398
399// ---------------------------------------------------------------------------
400// Matrix helpers (private)
401// ---------------------------------------------------------------------------
402
403/// Create a `d x d` identity matrix.
404fn identity_matrix(d: usize) -> Vec<Vec<f64>> {
405    let mut m = vec![vec![0.0; d]; d];
406    for (i, row) in m.iter_mut().enumerate() {
407        row[i] = 1.0;
408    }
409    m
410}
411
412/// Check positive definiteness via a Cholesky decomposition.
413fn matrix_is_positive_definite(matrix: &[Vec<f64>]) -> bool {
414    let n = matrix.len();
415    let mut lower = vec![vec![0.0; n]; n];
416    for i in 0..n {
417        for j in 0..=i {
418            let correction: f64 = (0..j).map(|k| lower[i][k] * lower[j][k]).sum();
419            let residual = matrix[i][j] - correction;
420            if i == j {
421                if !residual.is_finite() || residual <= 0.0 {
422                    return false;
423                }
424                lower[i][j] = residual.sqrt();
425            } else {
426                lower[i][j] = residual / lower[j][j];
427                if !lower[i][j].is_finite() {
428                    return false;
429                }
430            }
431        }
432    }
433    true
434}
435
436/// Compute the inverse of a square matrix via Gauss-Jordan elimination with
437/// partial pivoting.
438///
439/// Returns an error if the matrix is singular (a zero pivot is encountered
440/// after pivoting).
441#[allow(clippy::needless_range_loop)]
442fn matrix_inverse(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, RillError> {
443    let n = matrix.len();
444    // Build the augmented matrix [A | I].
445    let mut aug = vec![vec![0.0; 2 * n]; n];
446    for i in 0..n {
447        for j in 0..n {
448            aug[i][j] = matrix[i][j];
449        }
450        aug[i][n + i] = 1.0;
451    }
452
453    // Forward elimination with partial pivoting.
454    for col in 0..n {
455        // Find the pivot row with the largest absolute value in this column.
456        let mut pivot = col;
457        let mut max_val = aug[col][col].abs();
458        for row in (col + 1)..n {
459            if aug[row][col].abs() > max_val {
460                max_val = aug[row][col].abs();
461                pivot = row;
462            }
463        }
464        if max_val < 1e-12 {
465            return Err(RillError::InvalidParameter {
466                name: "matrix",
467                value: 0.0,
468            });
469        }
470        if pivot != col {
471            aug.swap(col, pivot);
472        }
473        // Scale the pivot row so the pivot element becomes 1.
474        let pivot_val = aug[col][col];
475        for j in 0..(2 * n) {
476            aug[col][j] /= pivot_val;
477        }
478        // Eliminate all other rows.
479        for row in 0..n {
480            if row == col {
481                continue;
482            }
483            let factor = aug[row][col];
484            if factor == 0.0 {
485                continue;
486            }
487            for j in 0..(2 * n) {
488                aug[row][j] -= factor * aug[col][j];
489            }
490        }
491    }
492
493    // Extract the inverse from the right half of the augmented matrix.
494    let mut inv = vec![vec![0.0; n]; n];
495    for i in 0..n {
496        for j in 0..n {
497            inv[i][j] = aug[i][n + j];
498        }
499    }
500    Ok(inv)
501}
502
503/// Matrix-vector multiplication: `result = matrix * vector`.
504fn matrix_vector_mul(matrix: &[Vec<f64>], vector: &[f64]) -> Vec<f64> {
505    let n = matrix.len();
506    let vector = &vector[..n];
507    matrix
508        .iter()
509        .map(|row| {
510            row[..n]
511                .iter()
512                .zip(vector)
513                .map(|(matrix_value, vector_value)| matrix_value * vector_value)
514                .sum()
515        })
516        .collect()
517}
518
519/// Dot product of two slices.
520fn dot(a: &[f64], b: &[f64]) -> f64 {
521    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
522}
523
524/// Quadratic form `x^T * matrix * x`.
525fn quadratic_form(x: &[f64], matrix: &[Vec<f64>]) -> f64 {
526    let n = x.len();
527    let mut result = 0.0;
528    for i in 0..n {
529        for j in 0..n {
530            result += x[i] * matrix[i][j] * x[j];
531        }
532    }
533    result
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539    use rand::SeedableRng;
540    use rand_chacha::ChaCha8Rng;
541
542    fn make_bandit() -> LinUcb {
543        LinUcb::new(LinUcbConfig {
544            alpha: 1.0,
545            arm_count: 3,
546            feature_count: 2,
547        })
548        .unwrap()
549    }
550
551    #[test]
552    fn rejects_zero_arm_count() {
553        let result = LinUcb::new(LinUcbConfig {
554            alpha: 1.0,
555            arm_count: 0,
556            feature_count: 2,
557        });
558        assert!(matches!(result, Err(RillError::InvalidArmCount(0))));
559    }
560
561    #[test]
562    fn rejects_zero_feature_count() {
563        let result = LinUcb::new(LinUcbConfig {
564            alpha: 1.0,
565            arm_count: 3,
566            feature_count: 0,
567        });
568        assert!(matches!(result, Err(RillError::InvalidFeatureCount(0))));
569    }
570
571    #[test]
572    fn rejects_invalid_alpha() {
573        for &bad in &[0.0, -1.0, f64::NAN, f64::INFINITY] {
574            let result = LinUcb::new(LinUcbConfig {
575                alpha: bad,
576                arm_count: 3,
577                feature_count: 2,
578            });
579            assert!(matches!(result, Err(RillError::InvalidParameter { .. })));
580        }
581    }
582
583    #[test]
584    fn initial_state() {
585        let b = make_bandit();
586        assert_eq!(b.arm_count(), 3);
587        assert_eq!(b.feature_count(), 2);
588        assert_eq!(b.samples_seen(), 0);
589        assert!((b.alpha() - 1.0).abs() < 1e-12);
590    }
591
592    #[test]
593    fn initial_a_is_identity() {
594        let b = make_bandit();
595        let a = b.a_matrix(0).unwrap();
596        assert!((a[0][0] - 1.0).abs() < 1e-12);
597        assert!((a[0][1] - 0.0).abs() < 1e-12);
598        assert!((a[1][0] - 0.0).abs() < 1e-12);
599        assert!((a[1][1] - 1.0).abs() < 1e-12);
600    }
601
602    #[test]
603    fn initial_b_is_zero() {
604        let b = make_bandit();
605        let bv = b.b_vector(0).unwrap();
606        assert!((bv[0] - 0.0).abs() < 1e-12);
607        assert!((bv[1] - 0.0).abs() < 1e-12);
608    }
609
610    #[test]
611    fn initial_ties_are_randomized() {
612        let b = make_bandit();
613        let mut rng = ChaCha8Rng::seed_from_u64(12);
614        let mut seen = std::collections::HashSet::new();
615        for _ in 0..100 {
616            seen.insert(b.select(&[0.5, 0.8], &mut rng).unwrap());
617        }
618        assert_eq!(seen.len(), b.arm_count());
619    }
620
621    #[test]
622    fn select_returns_valid_arm() {
623        let b = make_bandit();
624        let mut rng = ChaCha8Rng::seed_from_u64(42);
625        let context = [0.5, 0.8];
626        let arm = b.select(&context, &mut rng).unwrap();
627        assert!(arm < 3);
628    }
629
630    #[test]
631    fn update_modifies_a_and_b() {
632        let mut b = make_bandit();
633        let context = [0.5, 0.8];
634        b.update(0, &context, 1.0).unwrap();
635
636        let a = b.a_matrix(0).unwrap();
637        // A = I + x * x^T
638        assert!((a[0][0] - (1.0 + 0.5 * 0.5)).abs() < 1e-12);
639        assert!((a[0][1] - (0.5 * 0.8)).abs() < 1e-12);
640        assert!((a[1][0] - (0.8 * 0.5)).abs() < 1e-12);
641        assert!((a[1][1] - (1.0 + 0.8 * 0.8)).abs() < 1e-12);
642
643        let bv = b.b_vector(0).unwrap();
644        assert!((bv[0] - 0.5).abs() < 1e-12);
645        assert!((bv[1] - 0.8).abs() < 1e-12);
646        assert_eq!(b.samples_seen(), 1);
647    }
648
649    #[test]
650    fn update_does_not_affect_other_arms() {
651        let mut b = make_bandit();
652        let context = [0.5, 0.8];
653        b.update(0, &context, 1.0).unwrap();
654
655        // Arm 1 should still be at the initial state.
656        let a1 = b.a_matrix(1).unwrap();
657        assert!((a1[0][0] - 1.0).abs() < 1e-12);
658        let b1 = b.b_vector(1).unwrap();
659        assert!((b1[0] - 0.0).abs() < 1e-12);
660    }
661
662    #[test]
663    fn select_rejects_wrong_context_length() {
664        let b = make_bandit();
665        let mut rng = ChaCha8Rng::seed_from_u64(0);
666        assert!(b.select(&[0.5], &mut rng).is_err());
667        assert!(b.select(&[0.5, 0.8, 0.9], &mut rng).is_err());
668    }
669
670    #[test]
671    fn select_rejects_non_finite_context() {
672        let b = make_bandit();
673        let mut rng = ChaCha8Rng::seed_from_u64(0);
674        assert!(b.select(&[f64::NAN, 0.8], &mut rng).is_err());
675        assert!(b.select(&[0.5, f64::INFINITY], &mut rng).is_err());
676    }
677
678    #[test]
679    fn update_rejects_invalid_arm() {
680        let mut b = make_bandit();
681        let context = [0.5, 0.8];
682        assert!(b.update(3, &context, 1.0).is_err());
683    }
684
685    #[test]
686    fn update_rejects_wrong_context_length() {
687        let mut b = make_bandit();
688        assert!(b.update(0, &[0.5], 1.0).is_err());
689        assert!(b.update(0, &[0.5, 0.8, 0.9], 1.0).is_err());
690    }
691
692    #[test]
693    fn update_rejects_non_finite_reward() {
694        let mut b = make_bandit();
695        let context = [0.5, 0.8];
696        assert!(b.update(0, &context, f64::NAN).is_err());
697        assert!(b.update(0, &context, f64::INFINITY).is_err());
698    }
699
700    #[test]
701    fn update_rejects_arithmetic_overflow_without_mutating_state() {
702        let mut b = make_bandit();
703        let before = b.clone();
704        assert!(b.update(0, &[f64::MAX, f64::MAX], 1.0).is_err());
705        assert_eq!(b.a_matrices, before.a_matrices);
706        assert_eq!(b.b_vectors, before.b_vectors);
707        assert_eq!(b.samples_seen(), before.samples_seen());
708    }
709
710    #[test]
711    fn a_matrix_rejects_invalid_arm() {
712        let b = make_bandit();
713        assert!(b.a_matrix(5).is_err());
714    }
715
716    #[test]
717    fn b_vector_rejects_invalid_arm() {
718        let b = make_bandit();
719        assert!(b.b_vector(5).is_err());
720    }
721
722    #[test]
723    fn reset_clears_state() {
724        let mut b = make_bandit();
725        let context = [0.5, 0.8];
726        b.update(0, &context, 1.0).unwrap();
727        b.update(1, &context, 0.5).unwrap();
728        assert_eq!(b.samples_seen(), 2);
729
730        b.reset();
731        assert_eq!(b.samples_seen(), 0);
732        // A should be back to identity.
733        let a = b.a_matrix(0).unwrap();
734        assert!((a[0][0] - 1.0).abs() < 1e-12);
735        // b should be back to zero.
736        let bv = b.b_vector(0).unwrap();
737        assert!((bv[0] - 0.0).abs() < 1e-12);
738    }
739
740    #[test]
741    fn identity_matrix_inverse_is_identity() {
742        let ident = identity_matrix(3);
743        let inv = matrix_inverse(&ident).unwrap();
744        for (i, row) in inv.iter().enumerate() {
745            for (j, &val) in row.iter().enumerate() {
746                let expected = if i == j { 1.0 } else { 0.0 };
747                assert!((val - expected).abs() < 1e-12);
748            }
749        }
750    }
751
752    #[test]
753    fn known_2x2_matrix_inverse() {
754        // [[4, 7], [2, 6]] inverse = [[0.6, -0.7], [-0.2, 0.4]]
755        let matrix = vec![vec![4.0, 7.0], vec![2.0, 6.0]];
756        let inv = matrix_inverse(&matrix).unwrap();
757        assert!((inv[0][0] - 0.6).abs() < 1e-10);
758        assert!((inv[0][1] - (-0.7)).abs() < 1e-10);
759        assert!((inv[1][0] - (-0.2)).abs() < 1e-10);
760        assert!((inv[1][1] - 0.4).abs() < 1e-10);
761    }
762
763    #[test]
764    fn singular_matrix_inverse_returns_error() {
765        // A singular matrix (second row is a multiple of the first).
766        let matrix = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
767        let result = matrix_inverse(&matrix);
768        assert!(result.is_err());
769    }
770
771    #[test]
772    fn dot_product_correct() {
773        assert!((dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]) - 32.0).abs() < 1e-12);
774    }
775
776    #[test]
777    fn matrix_vector_mul_correct() {
778        let m = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
779        let v = vec![5.0, 6.0];
780        let r = matrix_vector_mul(&m, &v);
781        assert!((r[0] - 17.0).abs() < 1e-12);
782        assert!((r[1] - 39.0).abs() < 1e-12);
783    }
784
785    #[test]
786    fn quadratic_form_correct() {
787        // For identity matrix, x^T * I * x = sum(x_i^2)
788        let ident = identity_matrix(3);
789        let x = [1.0, 2.0, 3.0];
790        let q = quadratic_form(&x, &ident);
791        assert!((q - 14.0).abs() < 1e-12);
792    }
793
794    #[test]
795    fn contextual_selection_prefers_aligned_arm() {
796        // Two arms, 2-d context. Train arm 0 with context [1, 0] and reward 1,
797        // arm 1 with context [0, 1] and reward 1. When asked to select with
798        // context [1, 0], arm 0 should be preferred (its model aligns with
799        // this context).
800        let mut b = LinUcb::new(LinUcbConfig {
801            alpha: 0.1,
802            arm_count: 2,
803            feature_count: 2,
804        })
805        .unwrap();
806        let mut rng = ChaCha8Rng::seed_from_u64(7);
807
808        // Train arm 0 with context [1, 0] and high reward.
809        for _ in 0..20 {
810            b.update(0, &[1.0, 0.0], 1.0).unwrap();
811        }
812        // Train arm 1 with context [0, 1] and high reward.
813        for _ in 0..20 {
814            b.update(1, &[0.0, 1.0], 1.0).unwrap();
815        }
816
817        // Query with context [1, 0]: arm 0 should be selected.
818        let arm = b.select(&[1.0, 0.0], &mut rng).unwrap();
819        assert_eq!(arm, 0);
820
821        // Query with context [0, 1]: arm 1 should be selected.
822        let arm = b.select(&[0.0, 1.0], &mut rng).unwrap();
823        assert_eq!(arm, 1);
824    }
825
826    #[test]
827    fn learns_to_prefer_high_reward_arm() {
828        // 2 arms, 1-d context. Arm 0 gives reward proportional to context,
829        // arm 1 gives low reward. LinUCB should learn to prefer arm 0.
830        let mut b = LinUcb::new(LinUcbConfig {
831            alpha: 0.5,
832            arm_count: 2,
833            feature_count: 1,
834        })
835        .unwrap();
836        let mut rng = ChaCha8Rng::seed_from_u64(99);
837
838        for step in 1..=100 {
839            let x = step as f64 * 0.1;
840            let arm = b.select(&[x], &mut rng).unwrap();
841            // Arm 0: reward = 2*x; arm 1: reward = 0.1*x.
842            let reward = if arm == 0 { 2.0 * x } else { 0.1 * x };
843            b.update(arm, &[x], reward).unwrap();
844        }
845
846        // After learning, arm 0 should be selected for a typical context.
847        let final_arm = b.select(&[5.0], &mut rng).unwrap();
848        assert_eq!(final_arm, 0);
849    }
850
851    #[cfg(feature = "serde")]
852    #[test]
853    fn serde_roundtrip() {
854        let mut b = LinUcb::new(LinUcbConfig {
855            alpha: 1.5,
856            arm_count: 2,
857            feature_count: 3,
858        })
859        .unwrap();
860        b.update(0, &[1.0, 0.5, 0.2], 1.0).unwrap();
861        b.update(1, &[0.3, 0.7, 0.9], 0.5).unwrap();
862
863        let json = serde_json::to_string(&b).unwrap();
864        let restored: LinUcb = serde_json::from_str(&json).unwrap();
865        assert_eq!(restored.arm_count(), b.arm_count());
866        assert_eq!(restored.feature_count(), b.feature_count());
867        assert_eq!(restored.samples_seen(), b.samples_seen());
868        assert!((restored.alpha() - b.alpha()).abs() < 1e-12);
869        // Verify A matrix is preserved.
870        let orig_a = b.a_matrix(0).unwrap();
871        let rest_a = restored.a_matrix(0).unwrap();
872        for (orig_row, rest_row) in orig_a.iter().zip(rest_a.iter()) {
873            for (&o, &r) in orig_row.iter().zip(rest_row.iter()) {
874                assert!((o - r).abs() < 1e-12);
875            }
876        }
877    }
878
879    #[cfg(feature = "serde")]
880    #[test]
881    fn serde_rejects_malformed_state() {
882        let json = r#"{
883            "arm_count": 2,
884            "feature_count": 2,
885            "alpha": 1.0,
886            "a_matrices": [[[1.0, 0.0], [0.0, 1.0]]],
887            "b_vectors": [[0.0, 0.0]],
888            "samples_seen": 0
889        }"#;
890        assert!(serde_json::from_str::<LinUcb>(json).is_err());
891    }
892}