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 Cholesky factorisation and two
22//!   triangular solves per arm. This avoids explicitly forming a matrix
23//!   inverse and is numerically safer for the symmetric positive-definite
24//!   ridge matrices maintained by LinUCB.
25//! - `update`: `O(d^2)` for the outer-product accumulation on the selected arm
26//!   (other arms are untouched).
27//! - Space: `O(arm_count * d^2)`.
28//!
29//! ## Reference
30//!
31//! Li, Chu, Langford, Schapire. "A Contextual-Bandit Approach to Personalized
32//! News Article Recommendation." WWW 2010.
33
34use 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/// 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/// Explainable score components for one LinUCB arm.
99///
100/// [`exploration_bonus`](Self::exploration_bonus) already includes the
101/// configured `alpha` multiplier, so
102/// `total_score = exploitation + exploration_bonus`.
103#[derive(Debug, Clone, PartialEq)]
104pub struct LinUcbArmScore {
105    /// Zero-based arm index.
106    pub arm: usize,
107    /// Estimated reward `theta_a^T x`.
108    pub exploitation: f64,
109    /// Confidence bonus `alpha * sqrt(x^T A_a^-1 x)`.
110    pub exploration_bonus: f64,
111    /// The exact score used by selection.
112    pub total_score: f64,
113}
114
115/// Numerical diagnostics for one LinUCB arm's ridge matrix.
116#[derive(Debug, Clone, PartialEq)]
117pub struct LinUcbConditionDiagnostics {
118    /// Zero-based arm index.
119    pub arm: usize,
120    /// Smallest diagonal entry of the Cholesky factor.
121    pub min_cholesky_diagonal: f64,
122    /// Largest diagonal entry of the Cholesky factor.
123    pub max_cholesky_diagonal: f64,
124    /// A cheap condition indicator `(max_diag / min_diag)^2`.
125    ///
126    /// This is not an exact matrix condition number, but a large value is a
127    /// useful signal that the arm state is becoming poorly conditioned.
128    pub condition_indicator: f64,
129}
130
131/// LinUCB contextual multi-armed bandit.
132///
133/// Maintains a per-arm ridge-regression model and selects the arm with the
134/// highest upper confidence bound on the expected reward for the given
135/// context.
136///
137/// # Examples
138///
139/// ```
140/// use rill_ml::bandit::{ContextualBandit, LinUcb, LinUcbConfig};
141/// use rand::SeedableRng;
142/// use rand_chacha::ChaCha8Rng;
143///
144/// let mut config = LinUcbConfig::default();
145/// config.alpha = 1.0;
146/// config.arm_count = 2;
147/// config.feature_count = 2;
148/// let mut bandit = LinUcb::new(config).unwrap();
149/// let mut rng = ChaCha8Rng::seed_from_u64(0);
150///
151/// let context = [0.5, 0.8];
152/// let arm = bandit.select(&context, &mut rng).unwrap();
153/// bandit.update(arm, &context, 1.0).unwrap();
154/// assert_eq!(bandit.samples_seen(), 1);
155/// ```
156#[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    /// Per-arm `d x d` matrices `A_a`, initialized to the identity matrix.
163    a_matrices: Vec<Vec<Vec<f64>>>,
164    /// Per-arm `d` vectors `b_a`, initialized to zero.
165    b_vectors: Vec<Vec<f64>>,
166    /// Total number of updates.
167    samples_seen: u64,
168}
169
170impl LinUcb {
171    /// Create a new LinUCB bandit from the given configuration.
172    ///
173    /// # Errors
174    ///
175    /// Returns `RillError::InvalidArmCount` if `arm_count` is zero.
176    /// Returns `RillError::InvalidFeatureCount` if `feature_count` is zero.
177    /// Returns `RillError::InvalidParameter` if `alpha` is not finite and
178    /// positive.
179    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    /// The exploration parameter `alpha`.
197    pub const fn alpha(&self) -> f64 {
198        self.alpha
199    }
200
201    /// Borrow the `A` matrix for a specific arm (diagnostic).
202    ///
203    /// # Errors
204    ///
205    /// Returns `RillError::InvalidArm` if `arm` is out of range.
206    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    /// Borrow the `b` vector for a specific arm (diagnostic).
212    ///
213    /// # Errors
214    ///
215    /// Returns `RillError::InvalidArm` if `arm` is out of range.
216    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    /// Validate all persisted state invariants.
222    ///
223    /// This is also run automatically during deserialization.
224    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    /// Validate that the context vector has the expected length and is finite.
274    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    /// Compute the explainable UCB score for one arm.
293    ///
294    /// The returned exploration bonus already includes `alpha`.
295    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    /// Compute explainable UCB scores for every arm.
302    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    /// Select an arm with the existing randomized tie-break and return every
310    /// score used by that decision.
311    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    /// Select deterministically, resolving exact score ties to the lowest arm
322    /// index. This does not change the randomized [`ContextualBandit::select`]
323    /// contract and is intended for replay and audit paths.
324    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    /// Return a bounded numerical condition diagnostic for one arm.
339    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        // Solve A * theta = b without explicitly forming A^-1.
374        let theta = cholesky_solve(&lower, b)?;
375        // theta^T * x
376        let exploitation = checked_dot(&theta, context, "LinUCB exploitation")?;
377        // Solve A * z = x, then compute x^T z.
378        let solved_context = cholesky_solve(&lower, context)?;
379        let quad = checked_dot(context, &solved_context, "LinUCB exploration variance")?;
380        // Numerical safety: the quadratic form should be non-negative for a
381        // positive-definite A, but rounding can make it slightly negative.
382        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        // A finite symmetric update can still become numerically unusable at
454        // extreme scales. Reject before commit so scoring never observes a
455        // non-positive-definite arm state.
456        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/// Preview high-performance LinUCB implementation.
478///
479/// `LinUcbFast` maintains `A^-1` directly with the Sherman-Morrison rank-one
480/// update. Selection and update are `O(arm_count * d^2)` and `O(d^2)`
481/// respectively. Its serde state is Preview and is not part of the frozen
482/// [`LinUcb`] state schema.
483#[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    /// Create an empty fast LinUCB from the same configuration as [`LinUcb`].
496    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    /// Convert a stable LinUCB state into the Preview fast representation.
515    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    /// Borrow one cached inverse matrix.
535    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    /// Number of stored `f64` values, excluding allocator metadata.
541    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    /// Explain one arm score in `O(d^2)`.
546    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    /// Explain all arm scores in `O(arm_count * d^2)`.
553    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    /// Deterministic lowest-index tie-break for replay and audit.
561    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    /// Validate dimensions, finite values, symmetry and positive definiteness.
576    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        // Explicitly mirror the lower triangle to remove round-off asymmetry.
724        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
834// ---------------------------------------------------------------------------
835// Matrix helpers (private)
836// ---------------------------------------------------------------------------
837
838/// Create a `d x d` identity matrix.
839fn 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
847/// Check positive definiteness via a Cholesky decomposition.
848fn matrix_is_positive_definite(matrix: &[Vec<f64>]) -> bool {
849    cholesky_factor(matrix).is_ok()
850}
851
852/// Compute the lower-triangular Cholesky factor `L` where `A = L L^T`.
853fn 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
889/// Solve `L L^T x = rhs` for a Cholesky factor `L`.
890fn 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    // The mathematical inverse is symmetric. Mirroring removes harmless
949    // solve-order round-off before it enters the fast-state validator.
950    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/// Compute the inverse of a square matrix via Gauss-Jordan elimination with
999/// partial pivoting.
1000///
1001/// Returns an error if the matrix is singular (a zero pivot is encountered
1002/// after pivoting).
1003#[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    // Build the augmented matrix [A | I].
1008    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    // Forward elimination with partial pivoting.
1017    for col in 0..n {
1018        // Find the pivot row with the largest absolute value in this column.
1019        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        // Scale the pivot row so the pivot element becomes 1.
1037        let pivot_val = aug[col][col];
1038        for j in 0..(2 * n) {
1039            aug[col][j] /= pivot_val;
1040        }
1041        // Eliminate all other rows.
1042        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    // Extract the inverse from the right half of the augmented matrix.
1057    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/// Matrix-vector multiplication: `result = matrix * vector`.
1067#[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/// Dot product of two slices.
1084#[cfg(test)]
1085fn dot(a: &[f64], b: &[f64]) -> f64 {
1086    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
1087}
1088
1089/// Quadratic form `x^T * matrix * x`.
1090#[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        // A = I + x * x^T
1274        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        // Arm 1 should still be at the initial state.
1292        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        // A should be back to identity.
1425        let a = b.a_matrix(0).unwrap();
1426        assert!((a[0][0] - 1.0).abs() < 1e-12);
1427        // b should be back to zero.
1428        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        // [[4, 7], [2, 6]] inverse = [[0.6, -0.7], [-0.2, 0.4]]
1447        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        // A singular matrix (second row is a multiple of the first).
1458        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        // For identity matrix, x^T * I * x = sum(x_i^2)
1480        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        // Two arms, 2-d context. Train arm 0 with context [1, 0] and reward 1,
1489        // arm 1 with context [0, 1] and reward 1. When asked to select with
1490        // context [1, 0], arm 0 should be preferred (its model aligns with
1491        // this context).
1492        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        // Train arm 0 with context [1, 0] and high reward.
1501        for _ in 0..20 {
1502            b.update(0, &[1.0, 0.0], 1.0).unwrap();
1503        }
1504        // Train arm 1 with context [0, 1] and high reward.
1505        for _ in 0..20 {
1506            b.update(1, &[0.0, 1.0], 1.0).unwrap();
1507        }
1508
1509        // Query with context [1, 0]: arm 0 should be selected.
1510        let arm = b.select(&[1.0, 0.0], &mut rng).unwrap();
1511        assert_eq!(arm, 0);
1512
1513        // Query with context [0, 1]: arm 1 should be selected.
1514        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        // 2 arms, 1-d context. Arm 0 gives reward proportional to context,
1521        // arm 1 gives low reward. LinUCB should learn to prefer arm 0.
1522        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            // Arm 0: reward = 2*x; arm 1: reward = 0.1*x.
1534            let reward = if arm == 0 { 2.0 * x } else { 0.1 * x };
1535            b.update(arm, &[x], reward).unwrap();
1536        }
1537
1538        // After learning, arm 0 should be selected for a typical context.
1539        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        // Verify A matrix is preserved.
1654        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}