use std::collections::HashMap;
use tracing::debug;
struct StringInterner {
map: HashMap<String, u32>,
next_id: u32,
}
impl StringInterner {
fn new() -> Self {
Self {
map: HashMap::new(),
next_id: 0,
}
}
fn intern(&mut self, s: &str) -> u32 {
if let Some(&id) = self.map.get(s) {
return id;
}
let id = self.next_id;
self.next_id = self
.next_id
.checked_add(1)
.expect("StringInterner: exhausted u32 ID space (>4 billion unique strings)");
self.map.insert(s.to_string(), id);
id
}
}
#[non_exhaustive]
pub struct QLearner {
q_table: HashMap<(u32, u32), f64>,
interner: StringInterner,
learning_rate: f64,
discount_factor: f64,
}
impl QLearner {
pub fn new(learning_rate: f64, discount_factor: f64) -> Self {
Self {
q_table: HashMap::new(),
interner: StringInterner::new(),
learning_rate,
discount_factor,
}
}
#[must_use]
pub fn get_value(&self, state: &str, action: &str) -> f64 {
let s = self.interner.map.get(state);
let a = self.interner.map.get(action);
match (s, a) {
(Some(&s_id), Some(&a_id)) => self.q_table.get(&(s_id, a_id)).copied().unwrap_or(0.0),
_ => 0.0,
}
}
pub fn update(
&mut self,
state: &str,
action: &str,
reward: f64,
next_state: &str,
next_actions: &[&str],
) {
let current_q = self.get_value(state, action);
let max_next_q = next_actions
.iter()
.map(|a| self.get_value(next_state, a))
.fold(0.0_f64, f64::max);
let td_target = reward + self.discount_factor * max_next_q;
let new_q = current_q + self.learning_rate * (td_target - current_q);
debug!(state, action, reward, new_q, "q-value updated");
let s_id = self.interner.intern(state);
let a_id = self.interner.intern(action);
self.q_table.insert((s_id, a_id), new_q);
}
#[must_use]
pub fn best_action(&self, state: &str, actions: &[&str]) -> Option<String> {
actions
.iter()
.max_by(|a, b| {
self.get_value(state, a)
.partial_cmp(&self.get_value(state, b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|a| a.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_values_are_zero() {
let q = QLearner::new(0.1, 0.9);
assert!((q.get_value("s1", "a1") - 0.0).abs() < 1e-9);
assert!((q.get_value("any", "thing") - 0.0).abs() < 1e-9);
}
#[test]
fn update_changes_value() {
let mut q = QLearner::new(0.5, 0.9);
q.update("s1", "a1", 1.0, "s2", &["a1", "a2"]);
let val = q.get_value("s1", "a1");
assert!((val - 0.5).abs() < 1e-9);
}
#[test]
fn update_with_existing_next_state() {
let mut q = QLearner::new(0.5, 0.9);
q.update("s2", "a1", 2.0, "terminal", &[]);
q.update("s1", "a1", 1.0, "s2", &["a1", "a2"]);
let val = q.get_value("s1", "a1");
assert!((val - 0.95).abs() < 1e-9);
}
#[test]
fn best_action_selects_highest_q() {
let mut q = QLearner::new(0.1, 0.9);
q.update("s1", "a1", 3.0, "term", &[]);
q.update("s1", "a2", 9.0, "term", &[]);
q.update("s1", "a3", 5.0, "term", &[]);
let best = q.best_action("s1", &["a1", "a2", "a3"]).unwrap();
assert_eq!(best, "a2");
}
#[test]
fn best_action_empty_returns_none() {
let q = QLearner::new(0.1, 0.9);
assert!(q.best_action("s1", &[]).is_none());
}
#[test]
fn best_action_with_unseen_state() {
let q = QLearner::new(0.1, 0.9);
let best = q.best_action("unknown", &["a1", "a2"]);
assert!(best.is_some());
}
#[test]
fn repeated_updates_converge() {
let mut q = QLearner::new(0.1, 0.9);
for _ in 0..100 {
q.update("s1", "a1", 1.0, "s2", &["a1"]);
}
let val = q.get_value("s1", "a1");
assert!(
(val - 1.0).abs() < 0.01,
"Q-value should converge to ~1.0, got {val}"
);
}
#[test]
fn interning_reuses_ids() {
let mut q = QLearner::new(0.1, 0.9);
q.update("state", "action", 1.0, "next", &["a"]);
q.update("state", "action", 2.0, "next", &["a"]);
assert_eq!(q.interner.map.len(), 2); }
}