Skip to main content

ipfrs_tensorlogic/
reinforcement_learning_agent.rs

1//! Tabular reinforcement learning agent with multiple algorithms.
2//!
3//! Implements [`ReinforcementLearningAgent`] supporting:
4//! - **Policies**: Epsilon-Greedy (with decay), Boltzmann (softmax), UCB, Random
5//! - **Algorithms**: SARSA, Q-Learning, Expected SARSA, Double Q-Learning, N-Step TD
6//! - **Experience Replay**: circular buffer with uniform random sampling
7//! - **Eligibility Traces**: λ parameter for TD(λ) extensions
8//! - **Statistics**: per-episode and aggregate tracking
9//!
10//! No external RNG dependency — uses an inline xorshift64 PRNG throughout.
11//!
12//! # Example
13//!
14//! ```
15//! use ipfrs_tensorlogic::reinforcement_learning_agent::{
16//!     ReinforcementLearningAgent, RlState, RlAction, AgentConfig, AlgorithmType, AgentPolicy,
17//!     Transition,
18//! };
19//!
20//! let config = AgentConfig::default();
21//! let mut agent = ReinforcementLearningAgent::new(config);
22//!
23//! let s0 = RlState("s0".into());
24//! let a0 = RlAction("left".into());
25//! let a1 = RlAction("right".into());
26//! agent.register_state(s0.clone(), vec![a0.clone(), a1.clone()]).expect("example: should succeed in docs");
27//!
28//! let s1 = RlState("s1".into());
29//! agent.register_state(s1.clone(), vec![a0.clone(), a1.clone()]).expect("example: should succeed in docs");
30//!
31//! let t = Transition { state: s0.clone(), action: a0.clone(), reward: 1.0, next_state: s1.clone(), done: false };
32//! let _delta = agent.update(&t).expect("example: should succeed in docs");
33//! let best = agent.best_action(&s0).expect("example: should succeed in docs");
34//! assert!(best == a0 || best == a1);
35//! ```
36
37use std::collections::HashMap;
38
39mod rla_types;
40pub use rla_types::*;
41use rla_types::{NStepBuffer, QEntry};
42
43// ────────────────────────────────────────────────────────────────────────────
44// ReinforcementLearningAgent
45// ────────────────────────────────────────────────────────────────────────────
46
47/// Production-quality tabular RL agent supporting multiple algorithms and policies.
48///
49/// Register states and their valid actions, then call [`update`](Self::update)
50/// after each environment step to learn Q-values.  Call
51/// [`run_episode`](Self::run_episode) to process a complete episode at once.
52#[derive(Debug)]
53pub struct ReinforcementLearningAgent {
54    /// Agent configuration (algorithm, policy, hyperparameters).
55    config: AgentConfig,
56    /// Valid actions keyed by state.
57    state_actions: HashMap<RlState, Vec<RlAction>>,
58    /// Q-table: (state, action) → QEntry.
59    q_table: HashMap<(RlState, RlAction), QEntry>,
60    /// Eligibility traces: (state, action) → e(s,a).
61    eligibility: HashMap<(RlState, RlAction), f64>,
62    /// Toggle flag for Double Q-learning (which table to update).
63    double_q_toggle: bool,
64    /// N-step accumulation buffer (used only for NStepTD).
65    n_step_buf: NStepBuffer,
66    /// Experience replay buffer.
67    replay: ExperienceReplay,
68    /// Aggregate statistics.
69    stats: AgentStats,
70    /// Total visits across all (s,a) pairs (for UCB denominator).
71    total_visits: u64,
72    /// Maximum |ΔQ| during the last episode (tracks convergence).
73    last_episode_delta: f64,
74}
75
76impl ReinforcementLearningAgent {
77    /// Create a new agent with the given configuration.
78    ///
79    /// Returns [`RlAgentError::InvalidConfig`] if hyperparameters are out of range.
80    pub fn new(config: AgentConfig) -> Self {
81        let replay_cap = config.replay_capacity.max(1);
82        let n = match config.algorithm {
83            AlgorithmType::NStepTD(n) => n.max(1),
84            _ => 1,
85        };
86        Self {
87            replay: ExperienceReplay::new(replay_cap),
88            config,
89            state_actions: HashMap::new(),
90            q_table: HashMap::new(),
91            eligibility: HashMap::new(),
92            double_q_toggle: false,
93            n_step_buf: NStepBuffer::new(n),
94            stats: AgentStats {
95                episodes_run: 0,
96                total_steps: 0,
97                avg_reward: 0.0,
98                best_episode_reward: f64::NEG_INFINITY,
99                convergence_delta: 0.0,
100            },
101            total_visits: 0,
102            last_episode_delta: 0.0,
103        }
104    }
105
106    // ── Registration ─────────────────────────────────────────────────────────
107
108    /// Register `state` as a valid environment state with `actions` as its
109    /// legal action set.  Can be called multiple times to add more actions;
110    /// duplicate actions are ignored.
111    ///
112    /// # Errors
113    /// Returns [`RlAgentError::InvalidConfig`] when `actions` is empty.
114    pub fn register_state(
115        &mut self,
116        state: RlState,
117        actions: Vec<RlAction>,
118    ) -> Result<(), RlAgentError> {
119        if actions.is_empty() {
120            return Err(RlAgentError::InvalidConfig(format!(
121                "state {:?} must have at least one action",
122                state.0
123            )));
124        }
125        let entry = self.state_actions.entry(state.clone()).or_default();
126        for a in actions {
127            // Ensure Q-entry exists.
128            self.q_table.entry((state.clone(), a.clone())).or_default();
129            if !entry.contains(&a) {
130                entry.push(a);
131            }
132        }
133        Ok(())
134    }
135
136    // ── Action selection ─────────────────────────────────────────────────────
137
138    /// Select an action for `state` according to the configured policy.
139    ///
140    /// # Errors
141    /// - [`RlAgentError::StateNotFound`] if the state is not registered.
142    pub fn select_action(
143        &self,
144        state: &RlState,
145        rng_seed: &mut u64,
146    ) -> Result<RlAction, RlAgentError> {
147        let actions = self
148            .state_actions
149            .get(state)
150            .ok_or_else(|| RlAgentError::StateNotFound(state.clone()))?;
151
152        match &self.config.policy {
153            AgentPolicy::EpsilonGreedy { epsilon, .. } => {
154                let r = xorshift_f64(rng_seed);
155                if r < *epsilon {
156                    Ok(self.random_action(actions, rng_seed))
157                } else {
158                    Ok(self.greedy_action(state, actions))
159                }
160            }
161            AgentPolicy::Boltzmann { temperature } => {
162                Ok(self.boltzmann_action(state, actions, *temperature, rng_seed))
163            }
164            AgentPolicy::UCB { c } => Ok(self.ucb_action(state, actions, *c)),
165            AgentPolicy::Random => Ok(self.random_action(actions, rng_seed)),
166        }
167    }
168
169    /// Return the greedy (argmax Q) action for `state`.
170    ///
171    /// # Errors
172    /// - [`RlAgentError::StateNotFound`] if the state is not registered.
173    pub fn best_action(&self, state: &RlState) -> Result<RlAction, RlAgentError> {
174        let actions = self
175            .state_actions
176            .get(state)
177            .ok_or_else(|| RlAgentError::StateNotFound(state.clone()))?;
178        Ok(self.greedy_action(state, actions))
179    }
180
181    /// V(s) = max_a Q(s, a).  Returns 0.0 if the state is unregistered.
182    pub fn value(&self, state: &RlState) -> f64 {
183        match self.state_actions.get(state) {
184            None => 0.0,
185            Some(actions) => actions
186                .iter()
187                .map(|a| self.q1(state, a))
188                .fold(f64::NEG_INFINITY, f64::max),
189        }
190    }
191
192    // ── Q-table update ───────────────────────────────────────────────────────
193
194    /// Apply a single TD update from `transition`.
195    ///
196    /// Returns the absolute TD error |δ| so callers can track convergence.
197    ///
198    /// # Errors
199    /// - [`RlAgentError::StateNotFound`] — state or next_state not registered.
200    /// - [`RlAgentError::ActionNotFound`] — action not valid for state.
201    pub fn update(&mut self, transition: &Transition) -> Result<f64, RlAgentError> {
202        self.validate_transition(transition)?;
203
204        let delta = match self.config.algorithm.clone() {
205            AlgorithmType::QLearning => self.update_q_learning(transition),
206            AlgorithmType::Sarsa => self.update_sarsa(transition),
207            AlgorithmType::ExpectedSarsa => self.update_expected_sarsa(transition),
208            AlgorithmType::DoubleQLearning => self.update_double_q(transition),
209            AlgorithmType::NStepTD(_) => self.update_n_step(transition),
210        };
211
212        // Track eligibility traces (decay all entries after each step).
213        self.decay_eligibility();
214
215        // Track per-episode convergence.
216        if delta.abs() > self.last_episode_delta {
217            self.last_episode_delta = delta.abs();
218        }
219
220        // Update global visit counter.
221        self.total_visits += 1;
222        let entry = self
223            .q_table
224            .entry((transition.state.clone(), transition.action.clone()))
225            .or_default();
226        entry.visits += 1;
227
228        Ok(delta.abs())
229    }
230
231    // ── Episode runner ───────────────────────────────────────────────────────
232
233    /// Process a complete sequence of transitions as one episode.
234    ///
235    /// Epsilon is decayed once after all transitions are processed.
236    /// Episode statistics are accumulated into [`AgentStats`].
237    ///
238    /// # Errors
239    /// Propagates any error from [`update`](Self::update).
240    pub fn run_episode(
241        &mut self,
242        transitions: Vec<Transition>,
243        _rng_seed: u64,
244    ) -> Result<EpisodeStats, RlAgentError> {
245        if transitions.is_empty() {
246            return Ok(EpisodeStats {
247                total_reward: 0.0,
248                steps: 0,
249                epsilon: self.current_epsilon(),
250                avg_q_value: 0.0,
251            });
252        }
253
254        self.last_episode_delta = 0.0;
255        self.eligibility.clear();
256
257        let mut total_reward = 0.0;
258        let mut q_sum = 0.0;
259        let mut q_count = 0usize;
260
261        for t in &transitions {
262            // Register states on-the-fly if not yet seen (best-effort).
263            total_reward += t.reward;
264            let _ = self.update(t)?;
265            let q = self.q1(&t.state, &t.action);
266            q_sum += q;
267            q_count += 1;
268        }
269
270        let steps = transitions.len();
271        let eps = self.current_epsilon();
272
273        // Decay epsilon at end of episode.
274        self.decay_epsilon();
275
276        // Update aggregate stats.
277        let ema_alpha = 0.05_f64;
278        self.stats.avg_reward =
279            self.stats.avg_reward * (1.0 - ema_alpha) + total_reward * ema_alpha;
280        if total_reward > self.stats.best_episode_reward {
281            self.stats.best_episode_reward = total_reward;
282        }
283        self.stats.episodes_run += 1;
284        self.stats.total_steps += steps as u64;
285        self.stats.convergence_delta = self.last_episode_delta;
286
287        let avg_q = if q_count > 0 {
288            q_sum / q_count as f64
289        } else {
290            0.0
291        };
292
293        Ok(EpisodeStats {
294            total_reward,
295            steps,
296            epsilon: eps,
297            avg_q_value: avg_q,
298        })
299    }
300
301    // ── Epsilon decay ────────────────────────────────────────────────────────
302
303    /// Decay epsilon for EpsilonGreedy policies: ε ← max(min_ε, ε × decay).
304    /// No-op for other policies.
305    pub fn decay_epsilon(&mut self) {
306        if let AgentPolicy::EpsilonGreedy {
307            ref mut epsilon,
308            decay,
309            min_epsilon,
310        } = self.config.policy
311        {
312            *epsilon = (*epsilon * decay).max(min_epsilon);
313        }
314    }
315
316    // ── Experience replay ────────────────────────────────────────────────────
317
318    /// Push `t` into the experience replay buffer.
319    pub fn add_experience(&mut self, t: Transition) {
320        self.replay.push(t);
321    }
322
323    /// Draw `n` transitions uniformly at random from the replay buffer.
324    ///
325    /// # Errors
326    /// - [`RlAgentError::InsufficientExperience`] when the buffer has fewer than
327    ///   `n` entries.
328    pub fn sample_experience(
329        &self,
330        n: usize,
331        rng_seed: u64,
332    ) -> Result<Vec<Transition>, RlAgentError> {
333        let buf_len = self.replay.len();
334        if buf_len < n {
335            return Err(RlAgentError::InsufficientExperience(buf_len));
336        }
337        let mut seed = rng_seed ^ 0xdead_beef_cafe_u64;
338        let mut out = Vec::with_capacity(n);
339        // Reservoir / uniform sampling without replacement via partial Fisher-Yates.
340        let mut indices: Vec<usize> = (0..buf_len).collect();
341        for i in 0..n {
342            let j = i + (xorshift64(&mut seed) as usize % (buf_len - i));
343            indices.swap(i, j);
344            out.push(self.replay.buffer[indices[i]].clone());
345        }
346        Ok(out)
347    }
348
349    // ── Statistics ───────────────────────────────────────────────────────────
350
351    /// Return a snapshot of aggregate statistics.
352    pub fn stats(&self) -> AgentStats {
353        self.stats.clone()
354    }
355
356    // ────────────────────────────────────────────────────────────────────────
357    // Private helpers
358    // ────────────────────────────────────────────────────────────────────────
359
360    /// Read Q1(s, a), defaulting to 0.0.
361    fn q1(&self, state: &RlState, action: &RlAction) -> f64 {
362        self.q_table
363            .get(&(state.clone(), action.clone()))
364            .map_or(0.0, |e| e.q1)
365    }
366
367    /// Read Q2(s, a), defaulting to 0.0 (Double Q-learning only).
368    fn q2(&self, state: &RlState, action: &RlAction) -> f64 {
369        self.q_table
370            .get(&(state.clone(), action.clone()))
371            .map_or(0.0, |e| e.q2)
372    }
373
374    /// Max Q1 over all registered actions for `state`.
375    fn max_q1(&self, state: &RlState) -> f64 {
376        self.state_actions
377            .get(state)
378            .map(|acts| {
379                acts.iter()
380                    .map(|a| self.q1(state, a))
381                    .fold(f64::NEG_INFINITY, f64::max)
382            })
383            .unwrap_or(0.0)
384    }
385
386    /// Greedy argmax Q1 action.
387    fn greedy_action(&self, state: &RlState, actions: &[RlAction]) -> RlAction {
388        actions
389            .iter()
390            .max_by(|a, b| {
391                self.q1(state, a)
392                    .partial_cmp(&self.q1(state, b))
393                    .unwrap_or(std::cmp::Ordering::Equal)
394            })
395            .cloned()
396            .unwrap_or_else(|| actions[0].clone())
397    }
398
399    /// Uniform random action selection.
400    fn random_action(&self, actions: &[RlAction], rng_seed: &mut u64) -> RlAction {
401        let idx = xorshift64(rng_seed) as usize % actions.len();
402        actions[idx].clone()
403    }
404
405    /// Boltzmann (softmax) action selection.
406    fn boltzmann_action(
407        &self,
408        state: &RlState,
409        actions: &[RlAction],
410        temperature: f64,
411        rng_seed: &mut u64,
412    ) -> RlAction {
413        if temperature <= 0.0 {
414            return self.greedy_action(state, actions);
415        }
416        // Numerically stable softmax: shift by max.
417        let qs: Vec<f64> = actions.iter().map(|a| self.q1(state, a)).collect();
418        let max_q = qs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
419        let exps: Vec<f64> = qs
420            .iter()
421            .map(|&q| ((q - max_q) / temperature).exp())
422            .collect();
423        let sum: f64 = exps.iter().sum();
424        let r = xorshift_f64(rng_seed) * sum;
425        let mut cumulative = 0.0;
426        for (i, &e) in exps.iter().enumerate() {
427            cumulative += e;
428            if r <= cumulative {
429                return actions[i].clone();
430            }
431        }
432        actions[actions.len() - 1].clone()
433    }
434
435    /// UCB action selection: argmax[Q(s,a) + c * sqrt(ln(N) / (n_a + 1))].
436    fn ucb_action(&self, state: &RlState, actions: &[RlAction], c: f64) -> RlAction {
437        let ln_n = if self.total_visits > 0 {
438            (self.total_visits as f64).ln()
439        } else {
440            0.0
441        };
442        actions
443            .iter()
444            .max_by(|a, b| {
445                let visits_a = self
446                    .q_table
447                    .get(&(state.clone(), (*a).clone()))
448                    .map_or(0, |e| e.visits);
449                let visits_b = self
450                    .q_table
451                    .get(&(state.clone(), (*b).clone()))
452                    .map_or(0, |e| e.visits);
453                let ucb_a = self.q1(state, a) + c * (ln_n / (visits_a as f64 + 1.0)).sqrt();
454                let ucb_b = self.q1(state, b) + c * (ln_n / (visits_b as f64 + 1.0)).sqrt();
455                ucb_a
456                    .partial_cmp(&ucb_b)
457                    .unwrap_or(std::cmp::Ordering::Equal)
458            })
459            .cloned()
460            .unwrap_or_else(|| actions[0].clone())
461    }
462
463    /// Expected value of Q under the current ε-greedy policy over `actions`.
464    fn expected_q(&self, state: &RlState, actions: &[RlAction]) -> f64 {
465        let n = actions.len() as f64;
466        let eps = match &self.config.policy {
467            AgentPolicy::EpsilonGreedy { epsilon, .. } => *epsilon,
468            _ => 0.0,
469        };
470        let best = self.greedy_action(state, actions);
471        let random_contrib: f64 = actions.iter().map(|a| self.q1(state, a)).sum::<f64>() / n;
472        let greedy_contrib = self.q1(state, &best);
473        eps * random_contrib + (1.0 - eps) * greedy_contrib
474    }
475
476    /// Return the current epsilon (0.0 if policy is not EpsilonGreedy).
477    fn current_epsilon(&self) -> f64 {
478        if let AgentPolicy::EpsilonGreedy { epsilon, .. } = &self.config.policy {
479            *epsilon
480        } else {
481            0.0
482        }
483    }
484
485    /// Validate that both state and action in `t` are registered.
486    fn validate_transition(&self, t: &Transition) -> Result<(), RlAgentError> {
487        let actions = self
488            .state_actions
489            .get(&t.state)
490            .ok_or_else(|| RlAgentError::StateNotFound(t.state.clone()))?;
491        if !actions.contains(&t.action) {
492            return Err(RlAgentError::ActionNotFound {
493                state: t.state.clone(),
494                action: t.action.clone(),
495            });
496        }
497        // next_state must exist unless done (terminal states may be unregistered).
498        if !t.done && !self.state_actions.contains_key(&t.next_state) {
499            return Err(RlAgentError::StateNotFound(t.next_state.clone()));
500        }
501        Ok(())
502    }
503
504    // ── Algorithm implementations ─────────────────────────────────────────────
505
506    /// Q-learning update. Returns TD error δ.
507    fn update_q_learning(&mut self, t: &Transition) -> f64 {
508        let alpha = self.config.alpha;
509        let gamma = self.config.gamma;
510        let q_sa = self.q1(&t.state, &t.action);
511        let max_next = if t.done {
512            0.0
513        } else {
514            self.max_q1(&t.next_state)
515        };
516        let delta = t.reward + gamma * max_next - q_sa;
517        let entry = self
518            .q_table
519            .entry((t.state.clone(), t.action.clone()))
520            .or_default();
521        entry.q1 += alpha * delta;
522        delta
523    }
524
525    /// SARSA update. Returns TD error δ.
526    /// Uses greedy next action (on-policy under current policy).
527    fn update_sarsa(&mut self, t: &Transition) -> f64 {
528        let alpha = self.config.alpha;
529        let gamma = self.config.gamma;
530        let q_sa = self.q1(&t.state, &t.action);
531        // On-policy next action: greedy (argmax Q) for simplicity in tabular case.
532        let q_next = if t.done {
533            0.0
534        } else {
535            let next_actions = self
536                .state_actions
537                .get(&t.next_state)
538                .cloned()
539                .unwrap_or_default();
540            if next_actions.is_empty() {
541                0.0
542            } else {
543                let next_a = self.greedy_action(&t.next_state, &next_actions);
544                self.q1(&t.next_state, &next_a)
545            }
546        };
547        let delta = t.reward + gamma * q_next - q_sa;
548        // Eligibility trace update for SARSA(λ).
549        let lambda = self.config.lambda;
550        *self
551            .eligibility
552            .entry((t.state.clone(), t.action.clone()))
553            .or_insert(0.0) += 1.0;
554        // Update all entries proportional to their eligibility.
555        let keys: Vec<(RlState, RlAction)> = self.eligibility.keys().cloned().collect();
556        for key in keys {
557            let e = *self.eligibility.get(&key).unwrap_or(&0.0);
558            let entry = self.q_table.entry(key.clone()).or_default();
559            entry.q1 += alpha * delta * e;
560            let e_ref = self.eligibility.entry(key).or_insert(0.0);
561            *e_ref *= gamma * lambda;
562        }
563        delta
564    }
565
566    /// Expected SARSA update. Returns TD error δ.
567    fn update_expected_sarsa(&mut self, t: &Transition) -> f64 {
568        let alpha = self.config.alpha;
569        let gamma = self.config.gamma;
570        let q_sa = self.q1(&t.state, &t.action);
571        let expected_next = if t.done {
572            0.0
573        } else {
574            let next_actions = self
575                .state_actions
576                .get(&t.next_state)
577                .cloned()
578                .unwrap_or_default();
579            if next_actions.is_empty() {
580                0.0
581            } else {
582                self.expected_q(&t.next_state, &next_actions)
583            }
584        };
585        let delta = t.reward + gamma * expected_next - q_sa;
586        let entry = self
587            .q_table
588            .entry((t.state.clone(), t.action.clone()))
589            .or_default();
590        entry.q1 += alpha * delta;
591        delta
592    }
593
594    /// Double Q-learning update. Alternates which table is updated.  Returns TD error δ.
595    fn update_double_q(&mut self, t: &Transition) -> f64 {
596        let alpha = self.config.alpha;
597        let gamma = self.config.gamma;
598        self.double_q_toggle = !self.double_q_toggle;
599        let delta = if self.double_q_toggle {
600            // Update Q1 using Q2 for evaluation.
601            let q1_sa = self.q1(&t.state, &t.action);
602            let max_next = if t.done {
603                0.0
604            } else {
605                // Select action via Q1, evaluate via Q2.
606                let next_actions = self
607                    .state_actions
608                    .get(&t.next_state)
609                    .cloned()
610                    .unwrap_or_default();
611                if next_actions.is_empty() {
612                    0.0
613                } else {
614                    let best_a = self.greedy_action(&t.next_state, &next_actions);
615                    self.q2(&t.next_state, &best_a)
616                }
617            };
618            let delta = t.reward + gamma * max_next - q1_sa;
619            let entry = self
620                .q_table
621                .entry((t.state.clone(), t.action.clone()))
622                .or_default();
623            entry.q1 += alpha * delta;
624            delta
625        } else {
626            // Update Q2 using Q1 for evaluation.
627            let q2_sa = self.q2(&t.state, &t.action);
628            let max_next = if t.done {
629                0.0
630            } else {
631                // Select action via Q2, evaluate via Q1.
632                let next_actions = self
633                    .state_actions
634                    .get(&t.next_state)
635                    .cloned()
636                    .unwrap_or_default();
637                if next_actions.is_empty() {
638                    0.0
639                } else {
640                    let best_a = self
641                        .state_actions
642                        .get(&t.next_state)
643                        .and_then(|acts| {
644                            acts.iter()
645                                .max_by(|a, b| {
646                                    self.q2(&t.next_state, a)
647                                        .partial_cmp(&self.q2(&t.next_state, b))
648                                        .unwrap_or(std::cmp::Ordering::Equal)
649                                })
650                                .cloned()
651                        })
652                        .unwrap_or_else(|| next_actions[0].clone());
653                    self.q1(&t.next_state, &best_a)
654                }
655            };
656            let delta = t.reward + gamma * max_next - q2_sa;
657            let entry = self
658                .q_table
659                .entry((t.state.clone(), t.action.clone()))
660                .or_default();
661            entry.q2 += alpha * delta;
662            delta
663        };
664        delta
665    }
666
667    /// N-step TD update. Buffers transitions until n steps are available.
668    /// Returns 0.0 until the buffer is ready, then the actual TD error.
669    fn update_n_step(&mut self, t: &Transition) -> f64 {
670        self.n_step_buf.transitions.push_back(t.clone());
671        if !self.n_step_buf.ready() {
672            return 0.0;
673        }
674        let oldest = match self.n_step_buf.transitions.pop_front() {
675            Some(o) => o,
676            None => return 0.0,
677        };
678        let alpha = self.config.alpha;
679        let gamma = self.config.gamma;
680        let q_sa = self.q1(&oldest.state, &oldest.action);
681        // Bootstrap from the tail state.
682        let tail = self
683            .n_step_buf
684            .transitions
685            .back()
686            .map(|last| {
687                if last.done {
688                    0.0
689                } else {
690                    self.max_q1(&last.next_state)
691                }
692            })
693            .unwrap_or(0.0);
694        let g = self.n_step_buf.n_step_return(gamma, tail);
695        let delta = g - q_sa;
696        let entry = self
697            .q_table
698            .entry((oldest.state.clone(), oldest.action.clone()))
699            .or_default();
700        entry.q1 += alpha * delta;
701        delta
702    }
703
704    /// Decay all eligibility traces by γλ.
705    fn decay_eligibility(&mut self) {
706        let gamma = self.config.gamma;
707        let lambda = self.config.lambda;
708        let factor = gamma * lambda;
709        if (factor - 0.0).abs() < f64::EPSILON {
710            self.eligibility.clear();
711            return;
712        }
713        for e in self.eligibility.values_mut() {
714            *e *= factor;
715        }
716        // Remove negligibly small traces to keep memory bounded.
717        self.eligibility.retain(|_, e| e.abs() > 1e-10);
718    }
719}
720
721// ────────────────────────────────────────────────────────────────────────────
722// Tests
723// ────────────────────────────────────────────────────────────────────────────
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    // ── Helper macros / factories ─────────────────────────────────────────────
730
731    fn s(name: &str) -> RlState {
732        RlState(name.to_string())
733    }
734
735    fn a(name: &str) -> RlAction {
736        RlAction(name.to_string())
737    }
738
739    fn two_state_agent(algo: AlgorithmType, policy: AgentPolicy) -> ReinforcementLearningAgent {
740        let config = AgentConfig {
741            algorithm: algo,
742            policy,
743            alpha: 0.5,
744            gamma: 0.9,
745            lambda: 0.8,
746            replay_capacity: 100,
747            batch_size: 8,
748        };
749        let mut agent = ReinforcementLearningAgent::new(config);
750        agent
751            .register_state(s("A"), vec![a("left"), a("right")])
752            .expect("test: should succeed");
753        agent
754            .register_state(s("B"), vec![a("left"), a("right")])
755            .expect("test: should succeed");
756        agent
757    }
758
759    fn simple_transition(done: bool) -> Transition {
760        Transition {
761            state: s("A"),
762            action: a("left"),
763            reward: 1.0,
764            next_state: s("B"),
765            done,
766        }
767    }
768
769    // ── register_state ────────────────────────────────────────────────────────
770
771    #[test]
772    fn test_register_state_basic() {
773        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
774        agent
775            .register_state(s("s0"), vec![a("up"), a("down")])
776            .expect("test: should succeed");
777        assert!(agent.state_actions.contains_key(&s("s0")));
778        assert_eq!(agent.state_actions[&s("s0")].len(), 2);
779    }
780
781    #[test]
782    fn test_register_state_empty_actions_error() {
783        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
784        let result = agent.register_state(s("s0"), vec![]);
785        assert!(matches!(result, Err(RlAgentError::InvalidConfig(_))));
786    }
787
788    #[test]
789    fn test_register_state_dedup_actions() {
790        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
791        agent
792            .register_state(s("s0"), vec![a("up"), a("up"), a("down")])
793            .expect("test: should succeed");
794        assert_eq!(agent.state_actions[&s("s0")].len(), 2);
795    }
796
797    #[test]
798    fn test_register_state_multiple_calls_merge() {
799        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
800        agent
801            .register_state(s("s0"), vec![a("up")])
802            .expect("test: should succeed");
803        agent
804            .register_state(s("s0"), vec![a("down")])
805            .expect("test: should succeed");
806        assert_eq!(agent.state_actions[&s("s0")].len(), 2);
807    }
808
809    // ── select_action: EpsilonGreedy ─────────────────────────────────────────
810
811    #[test]
812    fn test_epsilon_greedy_high_epsilon_mostly_random() {
813        let policy = AgentPolicy::EpsilonGreedy {
814            epsilon: 1.0,
815            decay: 1.0,
816            min_epsilon: 1.0,
817        };
818        let agent = two_state_agent(AlgorithmType::QLearning, policy);
819        let mut seed = 42u64;
820        // With ε=1 all choices should be random — just verify no panic.
821        for _ in 0..20 {
822            let act = agent
823                .select_action(&s("A"), &mut seed)
824                .expect("test: should succeed");
825            assert!(act == a("left") || act == a("right"));
826        }
827    }
828
829    #[test]
830    fn test_epsilon_greedy_zero_epsilon_greedy() {
831        let policy = AgentPolicy::EpsilonGreedy {
832            epsilon: 0.0,
833            decay: 1.0,
834            min_epsilon: 0.0,
835        };
836        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
837        // Manually set Q so right is clearly better.
838        agent.q_table.entry((s("A"), a("right"))).or_default().q1 = 10.0;
839        let mut seed = 999u64;
840        let act = agent
841            .select_action(&s("A"), &mut seed)
842            .expect("test: should succeed");
843        assert_eq!(act, a("right"));
844    }
845
846    #[test]
847    fn test_epsilon_greedy_state_not_found() {
848        let policy = AgentPolicy::EpsilonGreedy {
849            epsilon: 0.1,
850            decay: 0.99,
851            min_epsilon: 0.01,
852        };
853        let agent = two_state_agent(AlgorithmType::QLearning, policy);
854        let mut seed = 1u64;
855        let result = agent.select_action(&s("UNKNOWN"), &mut seed);
856        assert!(matches!(result, Err(RlAgentError::StateNotFound(_))));
857    }
858
859    // ── select_action: Boltzmann ─────────────────────────────────────────────
860
861    #[test]
862    fn test_boltzmann_returns_valid_action() {
863        let policy = AgentPolicy::Boltzmann { temperature: 1.0 };
864        let agent = two_state_agent(AlgorithmType::QLearning, policy);
865        let mut seed = 7u64;
866        for _ in 0..30 {
867            let act = agent
868                .select_action(&s("A"), &mut seed)
869                .expect("test: should succeed");
870            assert!(act == a("left") || act == a("right"));
871        }
872    }
873
874    #[test]
875    fn test_boltzmann_zero_temperature_greedy() {
876        let policy = AgentPolicy::Boltzmann { temperature: 0.0 };
877        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
878        agent.q_table.entry((s("A"), a("left"))).or_default().q1 = 5.0;
879        agent.q_table.entry((s("A"), a("right"))).or_default().q1 = -1.0;
880        let mut seed = 0u64;
881        let act = agent
882            .select_action(&s("A"), &mut seed)
883            .expect("test: should succeed");
884        assert_eq!(act, a("left"));
885    }
886
887    #[test]
888    fn test_boltzmann_high_temperature_distribution() {
889        // High temperature → uniform-like distribution: both actions should appear.
890        let policy = AgentPolicy::Boltzmann {
891            temperature: 1000.0,
892        };
893        let agent = two_state_agent(AlgorithmType::QLearning, policy);
894        let mut seed = 13u64;
895        let mut left = 0u32;
896        let mut right = 0u32;
897        for _ in 0..200 {
898            match agent
899                .select_action(&s("A"), &mut seed)
900                .expect("test: should succeed")
901            {
902                x if x == a("left") => left += 1,
903                _ => right += 1,
904            }
905        }
906        // Both should appear at least once.
907        assert!(left > 0);
908        assert!(right > 0);
909    }
910
911    // ── select_action: UCB ────────────────────────────────────────────────────
912
913    #[test]
914    fn test_ucb_returns_valid_action() {
915        let policy = AgentPolicy::UCB { c: 1.0 };
916        let agent = two_state_agent(AlgorithmType::QLearning, policy);
917        let act = agent
918            .select_action(&s("A"), &mut 0u64)
919            .expect("test: should succeed");
920        assert!(act == a("left") || act == a("right"));
921    }
922
923    #[test]
924    fn test_ucb_with_many_visits() {
925        let policy = AgentPolicy::UCB { c: 0.5 };
926        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
927        agent.total_visits = 1000;
928        agent.q_table.entry((s("A"), a("right"))).or_default().q1 = 2.0;
929        agent
930            .q_table
931            .entry((s("A"), a("right")))
932            .or_default()
933            .visits = 500;
934        agent.q_table.entry((s("A"), a("left"))).or_default().visits = 500;
935        let act = agent
936            .select_action(&s("A"), &mut 0u64)
937            .expect("test: should succeed");
938        assert!(act == a("left") || act == a("right"));
939    }
940
941    // ── select_action: Random ────────────────────────────────────────────────
942
943    #[test]
944    fn test_random_policy_all_actions_reachable() {
945        let agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
946        let mut seed = 17u64;
947        let mut seen_left = false;
948        let mut seen_right = false;
949        for _ in 0..100 {
950            match agent
951                .select_action(&s("A"), &mut seed)
952                .expect("test: should succeed")
953            {
954                x if x == a("left") => seen_left = true,
955                _ => seen_right = true,
956            }
957        }
958        assert!(seen_left && seen_right);
959    }
960
961    // ── update: QLearning ────────────────────────────────────────────────────
962
963    #[test]
964    fn test_qlearning_update_increases_q() {
965        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
966        let t = simple_transition(false);
967        let before = agent.q1(&s("A"), &a("left"));
968        let delta = agent.update(&t).expect("test: TD update should succeed");
969        let after = agent.q1(&s("A"), &a("left"));
970        assert!(delta >= 0.0);
971        assert!(after > before);
972    }
973
974    #[test]
975    fn test_qlearning_terminal_no_bootstrap() {
976        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
977        let t = simple_transition(true);
978        agent.update(&t).expect("test: TD update should succeed");
979        // With done=true bootstrap is 0, so Q = alpha * reward = 0.5 * 1.0 = 0.5.
980        assert!((agent.q1(&s("A"), &a("left")) - 0.5).abs() < 1e-9);
981    }
982
983    #[test]
984    fn test_qlearning_converges_to_optimal() {
985        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
986        let t_right = Transition {
987            state: s("A"),
988            action: a("right"),
989            reward: 10.0,
990            next_state: s("B"),
991            done: true,
992        };
993        let t_left = Transition {
994            state: s("A"),
995            action: a("left"),
996            reward: 1.0,
997            next_state: s("B"),
998            done: true,
999        };
1000        for _ in 0..50 {
1001            agent
1002                .update(&t_right)
1003                .expect("test: TD update should succeed");
1004            agent
1005                .update(&t_left)
1006                .expect("test: TD update should succeed");
1007        }
1008        assert!(agent.q1(&s("A"), &a("right")) > agent.q1(&s("A"), &a("left")));
1009        assert_eq!(
1010            agent.best_action(&s("A")).expect("test: should succeed"),
1011            a("right")
1012        );
1013    }
1014
1015    // ── update: Sarsa ────────────────────────────────────────────────────────
1016
1017    #[test]
1018    fn test_sarsa_update_basic() {
1019        let mut agent = two_state_agent(AlgorithmType::Sarsa, AgentPolicy::Random);
1020        let t = simple_transition(false);
1021        let delta = agent.update(&t).expect("test: TD update should succeed");
1022        assert!(delta >= 0.0);
1023    }
1024
1025    #[test]
1026    fn test_sarsa_eligibility_traces_populated() {
1027        let mut agent = two_state_agent(AlgorithmType::Sarsa, AgentPolicy::Random);
1028        let t = simple_transition(false);
1029        agent.update(&t).expect("test: TD update should succeed");
1030        // After SARSA update, eligibility map should have entries.
1031        assert!(!agent.eligibility.is_empty());
1032    }
1033
1034    #[test]
1035    fn test_sarsa_terminal_state() {
1036        let mut agent = two_state_agent(AlgorithmType::Sarsa, AgentPolicy::Random);
1037        let t = simple_transition(true);
1038        agent.update(&t).expect("test: TD update should succeed");
1039        assert!(agent.q1(&s("A"), &a("left")) > 0.0);
1040    }
1041
1042    // ── update: ExpectedSarsa ────────────────────────────────────────────────
1043
1044    #[test]
1045    fn test_expected_sarsa_basic() {
1046        let policy = AgentPolicy::EpsilonGreedy {
1047            epsilon: 0.1,
1048            decay: 0.99,
1049            min_epsilon: 0.01,
1050        };
1051        let mut agent = two_state_agent(AlgorithmType::ExpectedSarsa, policy);
1052        let t = simple_transition(false);
1053        let delta = agent.update(&t).expect("test: TD update should succeed");
1054        assert!(delta >= 0.0);
1055        assert!(agent.q1(&s("A"), &a("left")) != 0.0);
1056    }
1057
1058    #[test]
1059    fn test_expected_sarsa_terminal() {
1060        let policy = AgentPolicy::EpsilonGreedy {
1061            epsilon: 0.1,
1062            decay: 0.99,
1063            min_epsilon: 0.01,
1064        };
1065        let mut agent = two_state_agent(AlgorithmType::ExpectedSarsa, policy);
1066        let t = simple_transition(true);
1067        agent.update(&t).expect("test: TD update should succeed");
1068        assert!((agent.q1(&s("A"), &a("left")) - 0.5).abs() < 1e-9);
1069    }
1070
1071    // ── update: DoubleQLearning ──────────────────────────────────────────────
1072
1073    #[test]
1074    fn test_double_q_updates_alternating_tables() {
1075        let mut agent = two_state_agent(AlgorithmType::DoubleQLearning, AgentPolicy::Random);
1076        let t = simple_transition(false);
1077        agent.update(&t).expect("test: TD update should succeed"); // updates Q1
1078        let q1_after_1 = agent.q1(&s("A"), &a("left"));
1079        let q2_after_1 = agent.q2(&s("A"), &a("left"));
1080        agent.update(&t).expect("test: TD update should succeed"); // updates Q2
1081        let q2_after_2 = agent.q2(&s("A"), &a("left"));
1082        // Q1 unchanged after second update.
1083        assert!((agent.q1(&s("A"), &a("left")) - q1_after_1).abs() < 1e-12);
1084        // Q2 should have changed on second update.
1085        assert!(q2_after_2 != q2_after_1);
1086    }
1087
1088    #[test]
1089    fn test_double_q_terminal() {
1090        let mut agent = two_state_agent(AlgorithmType::DoubleQLearning, AgentPolicy::Random);
1091        let t = simple_transition(true);
1092        agent.update(&t).expect("test: TD update should succeed");
1093        // First toggle updates Q1.
1094        assert!(agent.q1(&s("A"), &a("left")) != 0.0 || agent.q2(&s("A"), &a("left")) != 0.0);
1095    }
1096
1097    // ── update: NStepTD ──────────────────────────────────────────────────────
1098
1099    #[test]
1100    fn test_nstep_td_returns_zero_before_n_steps() {
1101        let config = AgentConfig {
1102            algorithm: AlgorithmType::NStepTD(3),
1103            policy: AgentPolicy::Random,
1104            alpha: 0.5,
1105            gamma: 0.9,
1106            lambda: 0.0,
1107            replay_capacity: 100,
1108            batch_size: 8,
1109        };
1110        let mut agent = ReinforcementLearningAgent::new(config);
1111        agent
1112            .register_state(s("A"), vec![a("left"), a("right")])
1113            .expect("test: should succeed");
1114        agent
1115            .register_state(s("B"), vec![a("left"), a("right")])
1116            .expect("test: should succeed");
1117
1118        let t = simple_transition(false);
1119        let d1 = agent.update(&t).expect("test: TD update should succeed");
1120        assert_eq!(d1, 0.0); // only 1 step buffered, need 3
1121        let d2 = agent.update(&t).expect("test: TD update should succeed");
1122        assert_eq!(d2, 0.0); // 2 steps
1123    }
1124
1125    #[test]
1126    fn test_nstep_td_updates_after_n_steps() {
1127        let config = AgentConfig {
1128            algorithm: AlgorithmType::NStepTD(2),
1129            policy: AgentPolicy::Random,
1130            alpha: 0.5,
1131            gamma: 0.9,
1132            lambda: 0.0,
1133            replay_capacity: 100,
1134            batch_size: 8,
1135        };
1136        let mut agent = ReinforcementLearningAgent::new(config);
1137        agent
1138            .register_state(s("A"), vec![a("left"), a("right")])
1139            .expect("test: should succeed");
1140        agent
1141            .register_state(s("B"), vec![a("left"), a("right")])
1142            .expect("test: should succeed");
1143
1144        let t = simple_transition(false);
1145        agent.update(&t).expect("test: TD update should succeed"); // step 1 — buffered
1146        let d3 = agent.update(&t).expect("test: TD update should succeed"); // step 2 — triggers update
1147                                                                            // Non-zero delta expected after n steps.
1148        assert!(d3 >= 0.0);
1149    }
1150
1151    #[test]
1152    fn test_nstep_td_n1_equivalent_to_qlearning() {
1153        // n=1 should behave identically to Q-learning.
1154        let config = AgentConfig {
1155            algorithm: AlgorithmType::NStepTD(1),
1156            policy: AgentPolicy::Random,
1157            alpha: 0.5,
1158            gamma: 0.9,
1159            lambda: 0.0,
1160            replay_capacity: 100,
1161            batch_size: 8,
1162        };
1163        let mut agent = ReinforcementLearningAgent::new(config);
1164        agent
1165            .register_state(s("A"), vec![a("left"), a("right")])
1166            .expect("test: should succeed");
1167        agent
1168            .register_state(s("B"), vec![a("left"), a("right")])
1169            .expect("test: should succeed");
1170        let t = simple_transition(true);
1171        let delta = agent.update(&t).expect("test: TD update should succeed");
1172        // n=1 buffer should be ready immediately.
1173        assert!(delta >= 0.0);
1174    }
1175
1176    // ── run_episode ──────────────────────────────────────────────────────────
1177
1178    #[test]
1179    fn test_run_episode_empty() {
1180        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1181        let stats = agent
1182            .run_episode(vec![], 42)
1183            .expect("test: episode run should succeed");
1184        assert_eq!(stats.steps, 0);
1185        assert_eq!(stats.total_reward, 0.0);
1186    }
1187
1188    #[test]
1189    fn test_run_episode_single_transition() {
1190        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1191        let t = simple_transition(false);
1192        let stats = agent
1193            .run_episode(vec![t], 1)
1194            .expect("test: episode run should succeed");
1195        assert_eq!(stats.steps, 1);
1196        assert_eq!(stats.total_reward, 1.0);
1197    }
1198
1199    #[test]
1200    fn test_run_episode_accumulates_reward() {
1201        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1202        let transitions = vec![
1203            Transition {
1204                state: s("A"),
1205                action: a("left"),
1206                reward: 2.0,
1207                next_state: s("B"),
1208                done: false,
1209            },
1210            Transition {
1211                state: s("B"),
1212                action: a("right"),
1213                reward: 3.0,
1214                next_state: s("A"),
1215                done: true,
1216            },
1217        ];
1218        let stats = agent
1219            .run_episode(transitions, 0)
1220            .expect("test: episode run should succeed");
1221        assert_eq!(stats.steps, 2);
1222        assert!((stats.total_reward - 5.0).abs() < 1e-9);
1223    }
1224
1225    #[test]
1226    fn test_run_episode_updates_aggregate_stats() {
1227        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1228        assert_eq!(agent.stats().episodes_run, 0);
1229        let t = simple_transition(false);
1230        agent
1231            .run_episode(vec![t], 0)
1232            .expect("test: episode run should succeed");
1233        assert_eq!(agent.stats().episodes_run, 1);
1234        assert_eq!(agent.stats().total_steps, 1);
1235    }
1236
1237    #[test]
1238    fn test_run_episode_tracks_best_reward() {
1239        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1240        let t1 = Transition {
1241            state: s("A"),
1242            action: a("left"),
1243            reward: 5.0,
1244            next_state: s("B"),
1245            done: true,
1246        };
1247        let t2 = Transition {
1248            state: s("A"),
1249            action: a("left"),
1250            reward: 100.0,
1251            next_state: s("B"),
1252            done: true,
1253        };
1254        agent
1255            .run_episode(vec![t1], 0)
1256            .expect("test: episode run should succeed");
1257        agent
1258            .run_episode(vec![t2], 0)
1259            .expect("test: episode run should succeed");
1260        assert!((agent.stats().best_episode_reward - 100.0).abs() < 1e-9);
1261    }
1262
1263    #[test]
1264    fn test_run_episode_epsilon_in_stats() {
1265        let policy = AgentPolicy::EpsilonGreedy {
1266            epsilon: 0.5,
1267            decay: 0.9,
1268            min_epsilon: 0.01,
1269        };
1270        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
1271        let t = simple_transition(false);
1272        let stats = agent
1273            .run_episode(vec![t], 0)
1274            .expect("test: episode run should succeed");
1275        // epsilon should be 0.5 at capture point (before decay).
1276        assert!((stats.epsilon - 0.5).abs() < 1e-9);
1277        // After episode epsilon is decayed.
1278        assert!((agent.current_epsilon() - 0.45).abs() < 1e-9);
1279    }
1280
1281    // ── best_action / value ──────────────────────────────────────────────────
1282
1283    #[test]
1284    fn test_best_action_returns_highest_q() {
1285        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1286        agent.q_table.entry((s("A"), a("left"))).or_default().q1 = 1.0;
1287        agent.q_table.entry((s("A"), a("right"))).or_default().q1 = 5.0;
1288        assert_eq!(
1289            agent.best_action(&s("A")).expect("test: should succeed"),
1290            a("right")
1291        );
1292    }
1293
1294    #[test]
1295    fn test_best_action_unknown_state_error() {
1296        let agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1297        assert!(matches!(
1298            agent.best_action(&s("Z")),
1299            Err(RlAgentError::StateNotFound(_))
1300        ));
1301    }
1302
1303    #[test]
1304    fn test_value_equals_max_q() {
1305        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1306        agent.q_table.entry((s("A"), a("left"))).or_default().q1 = 2.0;
1307        agent.q_table.entry((s("A"), a("right"))).or_default().q1 = 7.0;
1308        assert!((agent.value(&s("A")) - 7.0).abs() < 1e-9);
1309    }
1310
1311    #[test]
1312    fn test_value_unregistered_state_zero() {
1313        let agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1314        assert_eq!(agent.value(&s("UNKNOWN")), 0.0);
1315    }
1316
1317    // ── decay_epsilon ────────────────────────────────────────────────────────
1318
1319    #[test]
1320    fn test_decay_epsilon_reduces_epsilon() {
1321        let policy = AgentPolicy::EpsilonGreedy {
1322            epsilon: 1.0,
1323            decay: 0.5,
1324            min_epsilon: 0.0,
1325        };
1326        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
1327        agent.decay_epsilon();
1328        assert!((agent.current_epsilon() - 0.5).abs() < 1e-12);
1329    }
1330
1331    #[test]
1332    fn test_decay_epsilon_respects_min() {
1333        let policy = AgentPolicy::EpsilonGreedy {
1334            epsilon: 0.01,
1335            decay: 0.1,
1336            min_epsilon: 0.05,
1337        };
1338        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
1339        agent.decay_epsilon();
1340        assert!((agent.current_epsilon() - 0.05).abs() < 1e-12);
1341    }
1342
1343    #[test]
1344    fn test_decay_epsilon_noop_for_boltzmann() {
1345        let policy = AgentPolicy::Boltzmann { temperature: 2.0 };
1346        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
1347        agent.decay_epsilon(); // should not panic
1348        assert_eq!(agent.current_epsilon(), 0.0);
1349    }
1350
1351    #[test]
1352    fn test_decay_epsilon_noop_for_random() {
1353        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1354        agent.decay_epsilon();
1355        assert_eq!(agent.current_epsilon(), 0.0);
1356    }
1357
1358    // ── experience replay ────────────────────────────────────────────────────
1359
1360    #[test]
1361    fn test_add_experience_populates_buffer() {
1362        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1363        assert_eq!(agent.replay.len(), 0);
1364        agent.add_experience(simple_transition(false));
1365        assert_eq!(agent.replay.len(), 1);
1366    }
1367
1368    #[test]
1369    fn test_add_experience_respects_capacity() {
1370        let config = AgentConfig {
1371            replay_capacity: 3,
1372            ..AgentConfig::default()
1373        };
1374        let mut agent = ReinforcementLearningAgent::new(config);
1375        agent
1376            .register_state(s("A"), vec![a("left"), a("right")])
1377            .expect("test: should succeed");
1378        agent
1379            .register_state(s("B"), vec![a("left"), a("right")])
1380            .expect("test: should succeed");
1381        for _ in 0..10 {
1382            agent.add_experience(simple_transition(false));
1383        }
1384        assert_eq!(agent.replay.len(), 3);
1385    }
1386
1387    #[test]
1388    fn test_sample_experience_correct_count() {
1389        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1390        for _ in 0..20 {
1391            agent.add_experience(simple_transition(false));
1392        }
1393        let sample = agent
1394            .sample_experience(5, 42)
1395            .expect("test: experience sampling should succeed");
1396        assert_eq!(sample.len(), 5);
1397    }
1398
1399    #[test]
1400    fn test_sample_experience_insufficient_error() {
1401        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1402        agent.add_experience(simple_transition(false)); // only 1
1403        let result = agent.sample_experience(5, 0);
1404        assert!(matches!(
1405            result,
1406            Err(RlAgentError::InsufficientExperience(1))
1407        ));
1408    }
1409
1410    #[test]
1411    fn test_sample_experience_empty_buffer_error() {
1412        let agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1413        let result = agent.sample_experience(1, 0);
1414        assert!(matches!(
1415            result,
1416            Err(RlAgentError::InsufficientExperience(0))
1417        ));
1418    }
1419
1420    #[test]
1421    fn test_sample_experience_randomness_different_seeds() {
1422        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1423        for i in 0..20u64 {
1424            agent.add_experience(Transition {
1425                state: s("A"),
1426                action: a("left"),
1427                reward: i as f64,
1428                next_state: s("B"),
1429                done: false,
1430            });
1431        }
1432        let s1: Vec<f64> = agent
1433            .sample_experience(5, 1)
1434            .expect("test: should succeed")
1435            .iter()
1436            .map(|t| t.reward)
1437            .collect();
1438        let s2: Vec<f64> = agent
1439            .sample_experience(5, 99999)
1440            .expect("test: should succeed")
1441            .iter()
1442            .map(|t| t.reward)
1443            .collect();
1444        // Very likely to differ with different seeds.
1445        // (Technically could be the same; accept if at least one element differs.)
1446        let any_diff = s1.iter().zip(&s2).any(|(a, b)| (a - b).abs() > 1e-12);
1447        let all_same = s1 == s2;
1448        assert!(any_diff || !all_same || s1.len() == 1);
1449    }
1450
1451    // ── stats ────────────────────────────────────────────────────────────────
1452
1453    #[test]
1454    fn test_stats_initial_values() {
1455        let agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1456        let stats = agent.stats();
1457        assert_eq!(stats.episodes_run, 0);
1458        assert_eq!(stats.total_steps, 0);
1459        assert_eq!(stats.avg_reward, 0.0);
1460    }
1461
1462    #[test]
1463    fn test_stats_convergence_delta_updates() {
1464        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1465        let t = simple_transition(false);
1466        agent
1467            .run_episode(vec![t], 0)
1468            .expect("test: episode run should succeed");
1469        // After one episode convergence_delta should be non-negative.
1470        assert!(agent.stats().convergence_delta >= 0.0);
1471    }
1472
1473    #[test]
1474    fn test_stats_avg_reward_ema() {
1475        let mut agent = two_state_agent(AlgorithmType::QLearning, AgentPolicy::Random);
1476        // Run several episodes with reward=10.
1477        for _ in 0..50 {
1478            let t = Transition {
1479                state: s("A"),
1480                action: a("left"),
1481                reward: 10.0,
1482                next_state: s("B"),
1483                done: true,
1484            };
1485            agent
1486                .run_episode(vec![t], 0)
1487                .expect("test: episode run should succeed");
1488        }
1489        // After many episodes with reward=10 the EMA should be close to 10.
1490        let avg = agent.stats().avg_reward;
1491        assert!(avg > 5.0, "avg_reward {avg} should be > 5");
1492    }
1493
1494    // ── error cases ──────────────────────────────────────────────────────────
1495
1496    #[test]
1497    fn test_update_unknown_state_error() {
1498        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
1499        let t = Transition {
1500            state: s("GHOST"),
1501            action: a("up"),
1502            reward: 0.0,
1503            next_state: s("GHOST2"),
1504            done: false,
1505        };
1506        assert!(matches!(
1507            agent.update(&t),
1508            Err(RlAgentError::StateNotFound(_))
1509        ));
1510    }
1511
1512    #[test]
1513    fn test_update_invalid_action_error() {
1514        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
1515        agent
1516            .register_state(s("X"), vec![a("go")])
1517            .expect("test: should succeed");
1518        agent
1519            .register_state(s("Y"), vec![a("go")])
1520            .expect("test: should succeed");
1521        let t = Transition {
1522            state: s("X"),
1523            action: a("FORBIDDEN"),
1524            reward: 1.0,
1525            next_state: s("Y"),
1526            done: false,
1527        };
1528        assert!(matches!(
1529            agent.update(&t),
1530            Err(RlAgentError::ActionNotFound { .. })
1531        ));
1532    }
1533
1534    #[test]
1535    fn test_update_next_state_not_found_non_terminal() {
1536        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
1537        agent
1538            .register_state(s("X"), vec![a("go")])
1539            .expect("test: should succeed");
1540        let t = Transition {
1541            state: s("X"),
1542            action: a("go"),
1543            reward: 1.0,
1544            next_state: s("UNREGISTERED"),
1545            done: false,
1546        };
1547        assert!(matches!(
1548            agent.update(&t),
1549            Err(RlAgentError::StateNotFound(_))
1550        ));
1551    }
1552
1553    #[test]
1554    fn test_update_terminal_next_state_unregistered_ok() {
1555        let mut agent = ReinforcementLearningAgent::new(AgentConfig::default());
1556        agent
1557            .register_state(s("X"), vec![a("go")])
1558            .expect("test: should succeed");
1559        let t = Transition {
1560            state: s("X"),
1561            action: a("go"),
1562            reward: 1.0,
1563            next_state: s("TERMINAL"),
1564            done: true,
1565        };
1566        // done=true → next_state need not be registered.
1567        assert!(agent.update(&t).is_ok());
1568    }
1569
1570    #[test]
1571    fn test_rlagent_error_display() {
1572        let e1 = RlAgentError::StateNotFound(s("X"));
1573        let e2 = RlAgentError::ActionNotFound {
1574            state: s("X"),
1575            action: a("go"),
1576        };
1577        let e3 = RlAgentError::InsufficientExperience(3);
1578        let e4 = RlAgentError::InvalidConfig("bad alpha".into());
1579        assert!(!e1.to_string().is_empty());
1580        assert!(!e2.to_string().is_empty());
1581        assert!(!e3.to_string().is_empty());
1582        assert!(!e4.to_string().is_empty());
1583    }
1584
1585    // ── PRNG helpers ─────────────────────────────────────────────────────────
1586
1587    #[test]
1588    fn test_xorshift64_non_zero_output() {
1589        let mut s = 1u64;
1590        for _ in 0..100 {
1591            let v = xorshift64(&mut s);
1592            assert_ne!(v, 0);
1593        }
1594    }
1595
1596    #[test]
1597    fn test_xorshift_f64_range() {
1598        let mut s = 12345u64;
1599        for _ in 0..1000 {
1600            let v = xorshift_f64(&mut s);
1601            assert!((0.0..1.0).contains(&v));
1602        }
1603    }
1604
1605    // ── UCB policy edge case ──────────────────────────────────────────────────
1606
1607    #[test]
1608    fn test_ucb_zero_c_behaves_like_greedy() {
1609        let policy = AgentPolicy::UCB { c: 0.0 };
1610        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
1611        agent.q_table.entry((s("A"), a("left"))).or_default().q1 = 100.0;
1612        let act = agent
1613            .select_action(&s("A"), &mut 0u64)
1614            .expect("test: should succeed");
1615        assert_eq!(act, a("left"));
1616    }
1617
1618    // ── ExperienceReplay struct ───────────────────────────────────────────────
1619
1620    #[test]
1621    fn test_experience_replay_is_empty() {
1622        let buf = ExperienceReplay::new(10);
1623        assert!(buf.is_empty());
1624    }
1625
1626    #[test]
1627    fn test_experience_replay_evicts_oldest() {
1628        let mut buf = ExperienceReplay::new(2);
1629        buf.push(Transition {
1630            state: s("A"),
1631            action: a("x"),
1632            reward: 1.0,
1633            next_state: s("B"),
1634            done: false,
1635        });
1636        buf.push(Transition {
1637            state: s("B"),
1638            action: a("y"),
1639            reward: 2.0,
1640            next_state: s("A"),
1641            done: false,
1642        });
1643        buf.push(Transition {
1644            state: s("A"),
1645            action: a("z"),
1646            reward: 3.0,
1647            next_state: s("B"),
1648            done: false,
1649        });
1650        assert_eq!(buf.len(), 2);
1651        // Oldest (reward=1) should be gone; most recent two remain.
1652        let rewards: Vec<f64> = buf.buffer.iter().map(|t| t.reward).collect();
1653        assert!(!rewards.contains(&1.0));
1654    }
1655
1656    // ── multi-episode learning ────────────────────────────────────────────────
1657
1658    #[test]
1659    fn test_multi_episode_qlearning_improves() {
1660        let policy = AgentPolicy::EpsilonGreedy {
1661            epsilon: 0.3,
1662            decay: 0.99,
1663            min_epsilon: 0.01,
1664        };
1665        let mut agent = two_state_agent(AlgorithmType::QLearning, policy);
1666        let good = Transition {
1667            state: s("A"),
1668            action: a("right"),
1669            reward: 10.0,
1670            next_state: s("B"),
1671            done: true,
1672        };
1673        for _ in 0..100 {
1674            agent
1675                .run_episode(vec![good.clone()], 0)
1676                .expect("test: should succeed");
1677        }
1678        assert!(agent.q1(&s("A"), &a("right")) > 0.0);
1679        assert_eq!(
1680            agent.best_action(&s("A")).expect("test: should succeed"),
1681            a("right")
1682        );
1683    }
1684
1685    // ── Double Q-learning extra coverage ─────────────────────────────────────
1686
1687    #[test]
1688    fn test_double_q_both_tables_nonzero_after_many_updates() {
1689        let mut agent = two_state_agent(AlgorithmType::DoubleQLearning, AgentPolicy::Random);
1690        let t = simple_transition(false);
1691        for _ in 0..20 {
1692            agent.update(&t).expect("test: TD update should succeed");
1693        }
1694        let q1 = agent.q1(&s("A"), &a("left"));
1695        let q2 = agent.q2(&s("A"), &a("left"));
1696        assert!(q1 != 0.0 || q2 != 0.0);
1697    }
1698}