pub const N_ACTIONS: usize = 5;
pub const N_FEATURES: usize = 8;
pub struct LinUCBAgent {
w: [[f32; N_FEATURES]; N_ACTIONS],
b: [f32; N_ACTIONS],
a: [[f32; N_FEATURES]; N_FEATURES],
a_inv: [[f32; N_FEATURES]; N_FEATURES],
pub lambda: f32,
pub alpha: f32,
pub alpha_lr: f32,
pub name: String,
}
impl Default for LinUCBAgent {
fn default() -> Self {
Self::new()
}
}
impl LinUCBAgent {
pub fn new() -> Self {
let lambda = 1.0_f32;
let alpha = 2.0_f32.sqrt();
let mut a = [[0.0_f32; N_FEATURES]; N_FEATURES];
#[allow(clippy::needless_range_loop)]
for i in 0..N_FEATURES {
a[i][i] = lambda;
}
let mut a_inv = [[0.0_f32; N_FEATURES]; N_FEATURES];
#[allow(clippy::needless_range_loop)]
for i in 0..N_FEATURES {
a_inv[i][i] = 1.0 / lambda;
}
Self {
w: [[0.0; N_FEATURES]; N_ACTIONS],
b: [0.0; N_ACTIONS],
a,
a_inv,
lambda,
alpha,
alpha_lr: 0.1,
name: "LinUCB-CPU".to_string(),
}
}
pub fn with_params(lambda: f32, alpha: f32, alpha_lr: f32) -> Self {
let mut agent = Self::new();
agent.lambda = lambda;
agent.alpha = alpha;
agent.alpha_lr = alpha_lr;
let mut a = [[0.0_f32; N_FEATURES]; N_FEATURES];
let mut a_inv = [[0.0_f32; N_FEATURES]; N_FEATURES];
#[allow(clippy::needless_range_loop)]
for i in 0..N_FEATURES {
a[i][i] = lambda;
a_inv[i][i] = 1.0 / lambda;
}
agent.a = a;
agent.a_inv = a_inv;
agent
}
#[inline(always)]
fn q_value_for_action(&self, action: usize, x: &[f32; N_FEATURES], ucb_bonus: f32) -> f32 {
let linear: f32 = dot(&self.w[action], x);
linear + self.b[action] + ucb_bonus
}
pub fn select(&self, features: &[f32; N_FEATURES]) -> (u32, f32) {
let ucb_bonus = self.alpha * self.compute_ucb_variance(features).max(0.0).sqrt();
let mut best_action = 0_u32;
let mut best_q = f32::NEG_INFINITY;
for a in 0..N_ACTIONS {
let q = self.q_value_for_action(a, features, ucb_bonus);
if q > best_q {
best_q = q;
best_action = a as u32;
}
}
(best_action, best_q)
}
pub fn get_q_values(&self, features: &[f32; N_FEATURES]) -> [f32; N_ACTIONS] {
let ucb_bonus = self.alpha * self.compute_ucb_variance(features).max(0.0).sqrt();
let mut out = [0.0_f32; N_ACTIONS];
for (a, slot) in out.iter_mut().enumerate() {
*slot = self.q_value_for_action(a, features, ucb_bonus);
}
out
}
#[inline]
pub fn compute_ucb_variance(&self, x: &[f32; N_FEATURES]) -> f32 {
let v = mat_vec_mul(&self.a_inv, x);
dot(x, &v).max(0.0)
}
pub fn update(&mut self, features: &[f32; N_FEATURES], action: u32, reward: f32) {
let a = action as usize;
assert!(
a < N_ACTIONS,
"action index {} out of range [0, {})",
a,
N_ACTIONS
);
let prediction = dot(&self.w[a], features) + self.b[a];
let delta = reward - prediction;
let lr = self.alpha_lr;
for (j, &fj) in features.iter().enumerate() {
self.w[a][j] += lr * delta * fj;
}
self.b[a] += lr * delta;
let u = mat_vec_mul(&self.a_inv, features);
let x_t_u = dot(features, &u);
let denom = 1.0 + x_t_u;
if denom.abs() > f32::EPSILON {
for i in 0..N_FEATURES {
for j in 0..N_FEATURES {
self.a_inv[i][j] -= (u[i] * u[j]) / denom;
}
}
}
for i in 0..N_FEATURES {
for j in 0..N_FEATURES {
self.a[i][j] += features[i] * features[j];
}
}
}
pub fn weight_vector(&self, action: usize) -> [f32; N_FEATURES] {
assert!(action < N_ACTIONS);
self.w[action]
}
pub fn intercept(&self, action: usize) -> f32 {
assert!(action < N_ACTIONS);
self.b[action]
}
pub fn covariance_matrix(&self) -> [[f32; N_FEATURES]; N_FEATURES] {
self.a
}
pub fn inverse_covariance(&self) -> [[f32; N_FEATURES]; N_FEATURES] {
self.a_inv
}
pub fn weight_norms(&self) -> [f32; N_ACTIONS] {
let mut norms = [0.0_f32; N_ACTIONS];
for (norm, weights) in norms.iter_mut().zip(self.w.iter()) {
let mut norm_sq = 0.0_f32;
for &w in weights {
norm_sq += w * w;
}
*norm = norm_sq.sqrt();
}
norms
}
}
#[inline(always)]
fn dot(a: &[f32; N_FEATURES], b: &[f32; N_FEATURES]) -> f32 {
let mut s = 0.0_f32;
for i in 0..N_FEATURES {
s += a[i] * b[i];
}
s
}
#[inline]
fn mat_vec_mul(m: &[[f32; N_FEATURES]; N_FEATURES], v: &[f32; N_FEATURES]) -> [f32; N_FEATURES] {
let mut out = [0.0_f32; N_FEATURES];
for i in 0..N_FEATURES {
for j in 0..N_FEATURES {
out[i] += m[i][j] * v[j];
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn select_is_deterministic() {
let agent = LinUCBAgent::new();
let features = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
let (a1, q1) = agent.select(&features);
let (a2, q2) = agent.select(&features);
assert_eq!(a1, a2, "action must be deterministic");
assert!((q1 - q2).abs() < 1e-6, "Q-score must be deterministic");
}
#[test]
fn fresh_agent_uniform_q_values() {
let agent = LinUCBAgent::new();
let features = [0.5_f32; N_FEATURES];
let qs = agent.get_q_values(&features);
let first = qs[0];
for (i, &q) in qs.iter().enumerate() {
assert!(
(q - first).abs() < 1e-6,
"action {i} has Q={q} != Q[0]={first} on fresh agent"
);
}
}
#[test]
fn select_returns_valid_action_index() {
let agent = LinUCBAgent::new();
let features = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7];
let (action, _) = agent.select(&features);
assert!(
(action as usize) < N_ACTIONS,
"action {action} out of [0, {N_ACTIONS})"
);
}
#[test]
fn q_values_are_unbounded() {
let mut agent = LinUCBAgent::new();
let features = [1.0_f32; N_FEATURES];
for _ in 0..200 {
agent.update(&features, 0, 100.0);
}
let qs = agent.get_q_values(&features);
assert!(
qs[0] > 1.0,
"Q[0] should be large after positive reward reinforcement: got {}",
qs[0]
);
}
#[test]
fn update_modifies_only_chosen_action_weights() {
let mut agent = LinUCBAgent::new();
let features = [0.5_f32; N_FEATURES];
let weights_before: Vec<[f32; N_FEATURES]> =
(0..N_ACTIONS).map(|a| agent.weight_vector(a)).collect();
agent.update(&features, 3, 1.0);
let w3_after = agent.weight_vector(3);
assert!(
w3_after != weights_before[3],
"W[3] should change after update(action=3)"
);
for a in 0..N_ACTIONS {
if a == 3 {
continue;
}
assert_eq!(
agent.weight_vector(a),
weights_before[a],
"W[{a}] should not change when action=3 was selected"
);
}
}
#[test]
fn zero_features_produce_finite_q_values() {
let agent = LinUCBAgent::new();
let features = [0.0_f32; N_FEATURES];
let qs = agent.get_q_values(&features);
for (i, &q) in qs.iter().enumerate() {
assert!(
q.is_finite(),
"Q[{i}] must be finite for zero features, got {q}"
);
}
}
#[test]
fn zero_reward_leaves_weights_unchanged() {
let mut agent = LinUCBAgent::new();
let features = [0.3_f32; N_FEATURES];
agent.update(&features, 3, 0.0); let w3 = agent.weight_vector(3);
for (j, &wj) in w3.iter().enumerate() {
assert!(wj.abs() < 1e-7, "W[3][{j}] = {wj} after zero-reward update");
}
}
#[test]
fn a_inv_is_consistent_with_a_after_updates() {
let mut agent = LinUCBAgent::new();
let features = [0.1, 0.2, 0.3, 0.4, 0.0, 0.6, 0.7, 0.8];
for _ in 0..5 {
agent.update(&features, 0, 1.0);
}
let a = agent.covariance_matrix();
let a_inv = agent.inverse_covariance();
let product = mat_mul_8(&a, &a_inv);
for i in 0..N_FEATURES {
for j in 0..N_FEATURES {
let expected = if i == j { 1.0 } else { 0.0 };
assert!(
(product[i][j] - expected).abs() < 1e-3,
"A * A_inv [{i}][{j}] = {} (expected {expected})",
product[i][j]
);
}
}
}
#[test]
fn convergence_toward_rewarded_action() {
let mut agent = LinUCBAgent::new();
let features = [0.1, 0.9, 0.2, 0.8, 0.3, 0.7, 0.4, 0.6];
for _ in 0..500 {
agent.update(&features, 3, 1.0); }
let (best, _) = agent.select(&features);
assert_eq!(
best, 3,
"after 500 positive rewards for action 3, argmax should be 3, got {best}"
);
}
#[test]
fn argmax_differentiates_between_rewarded_actions() {
let mut agent = LinUCBAgent::new();
let ctx_a = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let ctx_b = [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
for _ in 0..300 {
agent.update(&ctx_a, 2, 1.0);
agent.update(&ctx_b, 4, 1.0); }
let (best_a, _) = agent.select(&ctx_a);
let (best_b, _) = agent.select(&ctx_b);
assert_eq!(best_a, 2, "context A should select action 2, got {best_a}");
assert_eq!(
best_b,
4, "context B should select action 4, got {best_b}"
);
}
fn mat_mul_8(
a: &[[f32; N_FEATURES]; N_FEATURES],
b: &[[f32; N_FEATURES]; N_FEATURES],
) -> [[f32; N_FEATURES]; N_FEATURES] {
let mut out = [[0.0_f32; N_FEATURES]; N_FEATURES];
for i in 0..N_FEATURES {
for k in 0..N_FEATURES {
for j in 0..N_FEATURES {
out[i][j] += a[i][k] * b[k][j];
}
}
}
out
}
}