Skip to main content

ipfrs_tensorlogic/
reinforcement_learner.rs

1//! Reinforcement learning agents — Q-learning and SARSA for discrete action spaces.
2//!
3//! Provides [`ReinforcementLearner`] with support for:
4//! - Standard Q-learning (off-policy TD(0))
5//! - SARSA (on-policy TD(0))
6//! - Double Q-learning (reduces overestimation bias)
7//! - Epsilon-greedy, greedy, and random exploration policies
8//! - Per-step and per-episode statistics
9//!
10//! All random number generation uses an inline xorshift64 PRNG seeded at
11//! construction time so the implementation is dependency-free and deterministic.
12
13use std::collections::{HashMap, VecDeque};
14
15// ---------------------------------------------------------------------------
16// Primitive identifiers
17// ---------------------------------------------------------------------------
18
19/// Opaque identifier for an environment state.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct StateId(pub u64);
22
23/// Opaque identifier for an action.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct ActionId(pub u32);
26
27// ---------------------------------------------------------------------------
28// Algorithm selector
29// ---------------------------------------------------------------------------
30
31/// Reinforcement learning algorithm variant.
32#[derive(Debug, Clone, PartialEq)]
33pub enum RlAlgorithm {
34    /// Off-policy TD(0) with greedy target policy.
35    QLearning {
36        /// Learning rate α ∈ (0, 1].
37        alpha: f64,
38        /// Discount factor γ ∈ [0, 1].
39        gamma: f64,
40        /// Exploration rate ε ∈ [0, 1] for on-policy action selection.
41        epsilon: f64,
42    },
43    /// On-policy TD(0) — the target action is chosen by the current ε-greedy policy.
44    Sarsa {
45        /// Learning rate α ∈ (0, 1].
46        alpha: f64,
47        /// Discount factor γ ∈ [0, 1].
48        gamma: f64,
49        /// Exploration rate ε ∈ [0, 1].
50        epsilon: f64,
51    },
52    /// Double Q-learning — maintains two independent Q-tables and randomly selects
53    /// which to update at each step, reducing the maximisation bias present in
54    /// standard Q-learning.
55    DoubleQLearning {
56        /// Learning rate α ∈ (0, 1].
57        alpha: f64,
58        /// Discount factor γ ∈ [0, 1].
59        gamma: f64,
60        /// Exploration rate ε ∈ [0, 1].
61        epsilon: f64,
62    },
63}
64
65impl RlAlgorithm {
66    /// Return the (alpha, gamma, epsilon) triple regardless of variant.
67    pub fn hyperparams(&self) -> (f64, f64, f64) {
68        match *self {
69            RlAlgorithm::QLearning {
70                alpha,
71                gamma,
72                epsilon,
73            }
74            | RlAlgorithm::Sarsa {
75                alpha,
76                gamma,
77                epsilon,
78            }
79            | RlAlgorithm::DoubleQLearning {
80                alpha,
81                gamma,
82                epsilon,
83            } => (alpha, gamma, epsilon),
84        }
85    }
86}
87
88// ---------------------------------------------------------------------------
89// Experience tuple
90// ---------------------------------------------------------------------------
91
92/// A single (s, a, r, s', done) transition.
93#[derive(Debug, Clone)]
94pub struct Experience {
95    /// State at which the action was taken.
96    pub state: StateId,
97    /// Action that was taken.
98    pub action: ActionId,
99    /// Scalar reward received.
100    pub reward: f64,
101    /// Resulting next state.
102    pub next_state: StateId,
103    /// `true` if the episode terminated after this transition.
104    pub done: bool,
105}
106
107// ---------------------------------------------------------------------------
108// Exploration policy
109// ---------------------------------------------------------------------------
110
111/// Action-selection policy.
112#[derive(Debug, Clone, PartialEq)]
113pub enum Policy {
114    /// With probability `epsilon` choose a uniformly random action;
115    /// otherwise choose the greedy (argmax Q) action.
116    EpsilonGreedy { epsilon: f64 },
117    /// Always choose the action with the highest Q-value.
118    Greedy,
119    /// Always choose a uniformly random action.
120    Random,
121}
122
123// ---------------------------------------------------------------------------
124// Statistics
125// ---------------------------------------------------------------------------
126
127/// Aggregate statistics snapshot.
128#[derive(Debug, Clone, PartialEq)]
129pub struct RlStats {
130    /// Total environment steps taken across all episodes.
131    pub total_steps: u64,
132    /// Total episodes completed.
133    pub total_episodes: u64,
134    /// Number of distinct states whose Q-values have been initialised.
135    pub explored_states: usize,
136    /// Mean return over the last 100 episodes.
137    pub avg_return_last_100: f64,
138    /// Highest episode return observed so far.
139    pub best_return: f64,
140}
141
142// ---------------------------------------------------------------------------
143// Error type
144// ---------------------------------------------------------------------------
145
146/// Errors that can be returned by reinforcement learning operations.
147#[derive(Debug, Clone, PartialEq)]
148pub enum RlError {
149    /// The requested action index is out of range for the current action space.
150    InvalidAction(u32),
151    /// The state identifier is inconsistent (reserved for future validation).
152    InvalidState,
153}
154
155impl std::fmt::Display for RlError {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            RlError::InvalidAction(id) => write!(f, "invalid action id: {id}"),
159            RlError::InvalidState => write!(f, "invalid state"),
160        }
161    }
162}
163
164impl std::error::Error for RlError {}
165
166// ---------------------------------------------------------------------------
167// xorshift64 PRNG
168// ---------------------------------------------------------------------------
169
170/// Fast non-cryptographic PRNG (xorshift64).
171///
172/// The `state` must be non-zero; an initial seed of 0 is promoted to 1 by the
173/// constructor.
174#[inline]
175fn xorshift64(state: &mut u64) -> u64 {
176    *state ^= *state << 13;
177    *state ^= *state >> 7;
178    *state ^= *state << 17;
179    *state
180}
181
182/// Return a pseudo-random `f64` in `[0, 1)` using the given PRNG state.
183#[inline]
184fn rand_f64(state: &mut u64) -> f64 {
185    // Use the upper 53 bits for the mantissa.
186    let bits = xorshift64(state);
187    (bits >> 11) as f64 / (1u64 << 53) as f64
188}
189
190/// Return a pseudo-random `usize` in `[0, n)` using the given PRNG state.
191///
192/// Uses rejection sampling to avoid modulo bias.
193#[inline]
194fn rand_usize(state: &mut u64, n: usize) -> usize {
195    assert!(n > 0, "n must be positive");
196    let n64 = n as u64;
197    // Largest multiple of n that fits in u64
198    let limit = u64::MAX - (u64::MAX % n64);
199    loop {
200        let r = xorshift64(state);
201        if r < limit {
202            return (r % n64) as usize;
203        }
204    }
205}
206
207// ---------------------------------------------------------------------------
208// ReinforcementLearner
209// ---------------------------------------------------------------------------
210
211/// Tabular reinforcement-learning agent supporting Q-learning, SARSA, and
212/// Double Q-learning.
213///
214/// Q-values are stored in a `HashMap` and initialised lazily to 0.0 on first
215/// access, so memory consumption grows only with the number of visited states.
216#[derive(Debug, Clone)]
217pub struct ReinforcementLearner {
218    /// The learning algorithm and its hyper-parameters.
219    pub algorithm: RlAlgorithm,
220    /// Primary Q-table: `q_table[s][a]` = Q(s, a).
221    pub q_table: HashMap<StateId, Vec<f64>>,
222    /// Secondary Q-table used only by Double Q-learning.
223    pub q_table2: HashMap<StateId, Vec<f64>>,
224    /// Number of discrete actions.
225    pub n_actions: usize,
226    /// Total environment steps executed via [`update`](Self::update).
227    pub total_steps: u64,
228    /// Total episodes recorded via [`start_episode`](Self::start_episode) /
229    /// [`end_episode`](Self::end_episode).
230    pub total_episodes: u64,
231    /// Sliding window of episode returns (capped at 1 000 entries).
232    pub episode_returns: VecDeque<f64>,
233    /// Current xorshift64 PRNG state (always non-zero).
234    pub rng_state: u64,
235}
236
237impl ReinforcementLearner {
238    // -----------------------------------------------------------------------
239    // Construction
240    // -----------------------------------------------------------------------
241
242    /// Create a new learner with the given algorithm, action-space size, and
243    /// random seed.
244    ///
245    /// A seed of 0 is promoted to 1 to satisfy the xorshift64 requirement.
246    pub fn new(algorithm: RlAlgorithm, n_actions: usize, seed: u64) -> Self {
247        let rng_state = if seed == 0 { 1 } else { seed };
248        Self {
249            algorithm,
250            q_table: HashMap::new(),
251            q_table2: HashMap::new(),
252            n_actions,
253            total_steps: 0,
254            total_episodes: 0,
255            episode_returns: VecDeque::new(),
256            rng_state,
257        }
258    }
259
260    // -----------------------------------------------------------------------
261    // Q-table helpers
262    // -----------------------------------------------------------------------
263
264    /// Return a reference to the Q-value vector for `state` in the primary
265    /// table, inserting a zero-initialised vector if absent.
266    fn ensure_state(&mut self, state: StateId) -> &mut Vec<f64> {
267        let n = self.n_actions;
268        self.q_table
269            .entry(state)
270            .or_insert_with(|| vec![0.0_f64; n])
271    }
272
273    /// Same as [`ensure_state`] but for the secondary table.
274    fn ensure_state2(&mut self, state: StateId) -> &mut Vec<f64> {
275        let n = self.n_actions;
276        self.q_table2
277            .entry(state)
278            .or_insert_with(|| vec![0.0_f64; n])
279    }
280
281    /// Read Q(s, a) from the primary table without mutating it.
282    fn read_q(&self, state: StateId, action: ActionId) -> f64 {
283        self.q_table
284            .get(&state)
285            .and_then(|v| v.get(action.0 as usize).copied())
286            .unwrap_or(0.0)
287    }
288
289    /// Read Q(s, a) from the secondary table without mutating it.
290    fn read_q2(&self, state: StateId, action: ActionId) -> f64 {
291        self.q_table2
292            .get(&state)
293            .and_then(|v| v.get(action.0 as usize).copied())
294            .unwrap_or(0.0)
295    }
296
297    /// Return max_{a} Q(s, a) from the primary table; 0.0 if unseen.
298    fn max_q(&self, state: StateId) -> f64 {
299        self.q_table
300            .get(&state)
301            .and_then(|v| v.iter().copied().reduce(f64::max))
302            .unwrap_or(0.0)
303    }
304
305    /// Return the argmax action from the primary table; `ActionId(0)` if
306    /// the state is unseen (all Q-values tie at 0.0).
307    fn argmax_q(&self, state: StateId) -> ActionId {
308        match self.q_table.get(&state) {
309            None => ActionId(0),
310            Some(v) => {
311                let mut best_idx = 0usize;
312                let mut best_val = v[0];
313                for (i, &val) in v.iter().enumerate().skip(1) {
314                    if val > best_val {
315                        best_val = val;
316                        best_idx = i;
317                    }
318                }
319                ActionId(best_idx as u32)
320            }
321        }
322    }
323
324    /// Return the action that maximises Q1(s, a) + Q2(s, a), used by Double
325    /// Q-learning to select the bootstrap action.
326    fn argmax_q_sum(&self, state: StateId) -> ActionId {
327        let n = self.n_actions;
328        let v1 = self.q_table.get(&state);
329        let v2 = self.q_table2.get(&state);
330        let mut best_idx = 0usize;
331        let mut best_val = f64::NEG_INFINITY;
332        for i in 0..n {
333            let q1 = v1.and_then(|v| v.get(i).copied()).unwrap_or(0.0);
334            let q2 = v2.and_then(|v| v.get(i).copied()).unwrap_or(0.0);
335            let combined = q1 + q2;
336            if combined > best_val {
337                best_val = combined;
338                best_idx = i;
339            }
340        }
341        ActionId(best_idx as u32)
342    }
343
344    // -----------------------------------------------------------------------
345    // Action selection
346    // -----------------------------------------------------------------------
347
348    /// Select an action for `state` according to `policy`.
349    ///
350    /// - [`Policy::EpsilonGreedy`] — random with prob ε, else argmax Q(s, ·).
351    /// - [`Policy::Greedy`] — always argmax Q(s, ·).
352    /// - [`Policy::Random`] — uniformly random over the action space.
353    pub fn select_action(&mut self, state: StateId, policy: &Policy) -> ActionId {
354        match policy {
355            Policy::Greedy => {
356                // Ensure state exists so it's counted as explored.
357                self.ensure_state(state);
358                self.argmax_q(state)
359            }
360            Policy::Random => {
361                let idx = rand_usize(&mut self.rng_state, self.n_actions);
362                ActionId(idx as u32)
363            }
364            Policy::EpsilonGreedy { epsilon } => {
365                let r = rand_f64(&mut self.rng_state);
366                if r < *epsilon {
367                    let idx = rand_usize(&mut self.rng_state, self.n_actions);
368                    ActionId(idx as u32)
369                } else {
370                    // Ensure state exists so it's counted as explored.
371                    self.ensure_state(state);
372                    self.argmax_q(state)
373                }
374            }
375        }
376    }
377
378    // -----------------------------------------------------------------------
379    // TD update
380    // -----------------------------------------------------------------------
381
382    /// Apply one temporal-difference update from `experience`; return the TD
383    /// error δ.
384    ///
385    /// Also increments [`total_steps`](Self::total_steps).
386    pub fn update(&mut self, experience: &Experience) -> f64 {
387        let td_error = match self.algorithm.clone() {
388            RlAlgorithm::QLearning { alpha, gamma, .. } => {
389                self.update_q_learning(experience, alpha, gamma)
390            }
391            RlAlgorithm::Sarsa {
392                alpha,
393                gamma,
394                epsilon,
395            } => self.update_sarsa(experience, alpha, gamma, epsilon),
396            RlAlgorithm::DoubleQLearning { alpha, gamma, .. } => {
397                self.update_double_q(experience, alpha, gamma)
398            }
399        };
400        self.total_steps += 1;
401        td_error
402    }
403
404    /// Q-learning update: δ = r + γ * max_a Q(s', a) * (1-done) - Q(s, a).
405    fn update_q_learning(&mut self, exp: &Experience, alpha: f64, gamma: f64) -> f64 {
406        let q_sa = self.read_q(exp.state, exp.action);
407        let max_next = if exp.done {
408            0.0
409        } else {
410            self.max_q(exp.next_state)
411        };
412        let td_error = exp.reward + gamma * max_next - q_sa;
413
414        // Mutate
415        self.ensure_state(exp.state);
416        if let Some(v) = self.q_table.get_mut(&exp.state) {
417            let idx = exp.action.0 as usize;
418            if let Some(entry) = v.get_mut(idx) {
419                *entry += alpha * td_error;
420            }
421        }
422        td_error
423    }
424
425    /// SARSA update: a' is chosen by the current ε-greedy policy.
426    fn update_sarsa(&mut self, exp: &Experience, alpha: f64, gamma: f64, epsilon: f64) -> f64 {
427        let q_sa = self.read_q(exp.state, exp.action);
428
429        // Select next action on-policy.
430        let q_next_sa = if exp.done {
431            0.0
432        } else {
433            let next_action =
434                self.select_action(exp.next_state, &Policy::EpsilonGreedy { epsilon });
435            self.read_q(exp.next_state, next_action)
436        };
437
438        let td_error = exp.reward + gamma * q_next_sa - q_sa;
439
440        self.ensure_state(exp.state);
441        if let Some(v) = self.q_table.get_mut(&exp.state) {
442            let idx = exp.action.0 as usize;
443            if let Some(entry) = v.get_mut(idx) {
444                *entry += alpha * td_error;
445            }
446        }
447        td_error
448    }
449
450    /// Double Q-learning update — randomly choose which table to update.
451    ///
452    /// The target for table-1 updates uses the action selected by table-1 to
453    /// index into table-2 (and vice versa), which prevents the overestimation
454    /// bias of standard Q-learning.
455    fn update_double_q(&mut self, exp: &Experience, alpha: f64, gamma: f64) -> f64 {
456        // Coin flip: 0 → update table1, 1 → update table2.
457        let coin = xorshift64(&mut self.rng_state) & 1;
458
459        let td_error = if coin == 0 {
460            // Update Q1 using target from Q2.
461            let q_sa = self.read_q(exp.state, exp.action);
462            let target = if exp.done {
463                exp.reward
464            } else {
465                // a* = argmax_a Q1(s', a)
466                let a_star = self.argmax_q(exp.next_state);
467                // but evaluate it with Q2
468                exp.reward + gamma * self.read_q2(exp.next_state, a_star)
469            };
470            let delta = target - q_sa;
471            self.ensure_state(exp.state);
472            if let Some(v) = self.q_table.get_mut(&exp.state) {
473                if let Some(entry) = v.get_mut(exp.action.0 as usize) {
474                    *entry += alpha * delta;
475                }
476            }
477            delta
478        } else {
479            // Update Q2 using target from Q1.
480            let q_sa2 = self.read_q2(exp.state, exp.action);
481            let target = if exp.done {
482                exp.reward
483            } else {
484                // a* = argmax_a Q2(s', a)
485                let a_star = self.argmax_q_sum(exp.next_state); // symmetrically
486                                                                // evaluate with Q1
487                exp.reward + gamma * self.read_q(exp.next_state, a_star)
488            };
489            let delta = target - q_sa2;
490            self.ensure_state2(exp.state);
491            if let Some(v) = self.q_table2.get_mut(&exp.state) {
492                if let Some(entry) = v.get_mut(exp.action.0 as usize) {
493                    *entry += alpha * delta;
494                }
495            }
496            delta
497        };
498
499        td_error
500    }
501
502    // -----------------------------------------------------------------------
503    // Batch update
504    // -----------------------------------------------------------------------
505
506    /// Apply TD updates for a slice of experiences in order; return the
507    /// corresponding TD errors.
508    pub fn batch_update(&mut self, experiences: &[Experience]) -> Vec<f64> {
509        experiences.iter().map(|exp| self.update(exp)).collect()
510    }
511
512    // -----------------------------------------------------------------------
513    // Query methods
514    // -----------------------------------------------------------------------
515
516    /// Return the action with the highest Q-value in the primary table.
517    /// Falls back to `ActionId(0)` when the state has never been visited.
518    pub fn best_action(&self, state: StateId) -> ActionId {
519        self.argmax_q(state)
520    }
521
522    /// Return Q(s, a) from the primary table; 0.0 if the pair is unseen.
523    pub fn q_value(&self, state: StateId, action: ActionId) -> f64 {
524        self.read_q(state, action)
525    }
526
527    /// Return V(s) = max_a Q(s, a) from the primary table; 0.0 if unseen.
528    pub fn value(&self, state: StateId) -> f64 {
529        self.max_q(state)
530    }
531
532    /// Number of distinct states whose Q-values have been initialised.
533    pub fn explored_states(&self) -> usize {
534        self.q_table.len()
535    }
536
537    // -----------------------------------------------------------------------
538    // Episode management
539    // -----------------------------------------------------------------------
540
541    /// Signal the start of a new episode.
542    ///
543    /// Pushes a placeholder return of 0.0 and increments `total_episodes`.
544    pub fn start_episode(&mut self) {
545        self.episode_returns.push_back(0.0);
546        self.total_episodes += 1;
547    }
548
549    /// Signal the end of the current episode, recording its total return.
550    ///
551    /// Updates the last entry in `episode_returns` and caps the deque at 1 000
552    /// entries (oldest entries are dropped).
553    pub fn end_episode(&mut self, total_return: f64) {
554        if let Some(last) = self.episode_returns.back_mut() {
555            *last = total_return;
556        } else {
557            // end_episode called without a matching start_episode — record anyway.
558            self.episode_returns.push_back(total_return);
559        }
560        while self.episode_returns.len() > 1000 {
561            self.episode_returns.pop_front();
562        }
563    }
564
565    /// Return the mean episode return over the last `last_n` episodes.
566    /// Returns 0.0 if no episodes have been recorded.
567    pub fn avg_return(&self, last_n: usize) -> f64 {
568        if self.episode_returns.is_empty() || last_n == 0 {
569            return 0.0;
570        }
571        let n = last_n.min(self.episode_returns.len());
572        let start = self.episode_returns.len() - n;
573        let sum: f64 = self.episode_returns.iter().skip(start).sum();
574        sum / n as f64
575    }
576
577    // -----------------------------------------------------------------------
578    // Statistics
579    // -----------------------------------------------------------------------
580
581    /// Return a snapshot of the current learning statistics.
582    pub fn stats(&self) -> RlStats {
583        let best_return = self
584            .episode_returns
585            .iter()
586            .copied()
587            .fold(f64::NEG_INFINITY, f64::max);
588        let best_return = if best_return == f64::NEG_INFINITY {
589            0.0
590        } else {
591            best_return
592        };
593
594        RlStats {
595            total_steps: self.total_steps,
596            total_episodes: self.total_episodes,
597            explored_states: self.explored_states(),
598            avg_return_last_100: self.avg_return(100),
599            best_return,
600        }
601    }
602}
603
604// ---------------------------------------------------------------------------
605// Tests
606// ---------------------------------------------------------------------------
607
608#[cfg(test)]
609mod tests {
610    use super::{
611        ActionId, Experience, Policy, ReinforcementLearner, RlAlgorithm, RlError, StateId,
612    };
613
614    // -----------------------------------------------------------------------
615    // Helpers
616    // -----------------------------------------------------------------------
617
618    fn make_q_learner() -> ReinforcementLearner {
619        ReinforcementLearner::new(
620            RlAlgorithm::QLearning {
621                alpha: 0.1,
622                gamma: 0.9,
623                epsilon: 0.1,
624            },
625            4,
626            42,
627        )
628    }
629
630    fn make_sarsa() -> ReinforcementLearner {
631        ReinforcementLearner::new(
632            RlAlgorithm::Sarsa {
633                alpha: 0.1,
634                gamma: 0.9,
635                epsilon: 0.2,
636            },
637            4,
638            99,
639        )
640    }
641
642    fn make_double_q() -> ReinforcementLearner {
643        ReinforcementLearner::new(
644            RlAlgorithm::DoubleQLearning {
645                alpha: 0.1,
646                gamma: 0.9,
647                epsilon: 0.1,
648            },
649            4,
650            7,
651        )
652    }
653
654    fn exp(s: u64, a: u32, r: f64, ns: u64, done: bool) -> Experience {
655        Experience {
656            state: StateId(s),
657            action: ActionId(a),
658            reward: r,
659            next_state: StateId(ns),
660            done,
661        }
662    }
663
664    // -----------------------------------------------------------------------
665    // Construction
666    // -----------------------------------------------------------------------
667
668    #[test]
669    fn test_new_q_learning_initial_state() {
670        let learner = make_q_learner();
671        assert_eq!(learner.total_steps, 0);
672        assert_eq!(learner.total_episodes, 0);
673        assert_eq!(learner.n_actions, 4);
674        assert!(learner.q_table.is_empty());
675    }
676
677    #[test]
678    fn test_new_zero_seed_promoted() {
679        let learner = ReinforcementLearner::new(
680            RlAlgorithm::QLearning {
681                alpha: 0.1,
682                gamma: 0.9,
683                epsilon: 0.0,
684            },
685            2,
686            0,
687        );
688        assert_ne!(learner.rng_state, 0);
689    }
690
691    #[test]
692    fn test_new_sarsa_initial_state() {
693        let learner = make_sarsa();
694        assert_eq!(learner.n_actions, 4);
695        assert!(learner.q_table.is_empty());
696    }
697
698    #[test]
699    fn test_new_double_q_initial_state() {
700        let learner = make_double_q();
701        assert!(learner.q_table.is_empty());
702        assert!(learner.q_table2.is_empty());
703    }
704
705    // -----------------------------------------------------------------------
706    // Q-value access
707    // -----------------------------------------------------------------------
708
709    #[test]
710    fn test_q_value_unseen_state_returns_zero() {
711        let learner = make_q_learner();
712        assert_eq!(learner.q_value(StateId(999), ActionId(0)), 0.0);
713    }
714
715    #[test]
716    fn test_value_unseen_state_returns_zero() {
717        let learner = make_q_learner();
718        assert_eq!(learner.value(StateId(999)), 0.0);
719    }
720
721    #[test]
722    fn test_best_action_unseen_state_returns_zero_action() {
723        let learner = make_q_learner();
724        assert_eq!(learner.best_action(StateId(42)), ActionId(0));
725    }
726
727    // -----------------------------------------------------------------------
728    // Q-learning update
729    // -----------------------------------------------------------------------
730
731    #[test]
732    fn test_q_learning_update_increments_steps() {
733        let mut learner = make_q_learner();
734        learner.update(&exp(0, 0, 1.0, 1, false));
735        assert_eq!(learner.total_steps, 1);
736    }
737
738    #[test]
739    fn test_q_learning_update_from_zero() {
740        // Q(s,a) = 0; r=1; max Q(s',a) = 0; not done.
741        // δ = 1 + 0.9 * 0 - 0 = 1.0
742        // Q(s,a) += 0.1 * 1.0 → 0.1
743        let mut learner = make_q_learner();
744        let td = learner.update(&exp(0, 0, 1.0, 1, false));
745        assert!((td - 1.0).abs() < 1e-12);
746        assert!((learner.q_value(StateId(0), ActionId(0)) - 0.1).abs() < 1e-12);
747    }
748
749    #[test]
750    fn test_q_learning_terminal_step() {
751        // done=true → max Q(s', a) = 0 regardless of next-state values.
752        let mut learner = make_q_learner();
753        let td = learner.update(&exp(0, 1, 5.0, 99, true));
754        // δ = 5.0 + 0 - 0 = 5.0; Q(s,a) = 0.5
755        assert!((td - 5.0).abs() < 1e-12);
756        assert!((learner.q_value(StateId(0), ActionId(1)) - 0.5).abs() < 1e-12);
757    }
758
759    #[test]
760    fn test_q_learning_uses_max_next_action() {
761        let mut learner = make_q_learner();
762        // Seed next state values: update action 2 of state 1 to a high value.
763        learner.update(&exp(1, 2, 10.0, 2, true)); // Q(1,2) = 1.0
764                                                   // Now update from state 0 with next_state=1.
765                                                   // max Q(1, ·) = 1.0
766                                                   // δ = 0.5 + 0.9 * 1.0 - 0 = 1.4; Q(0,0) = 0.14
767        let td = learner.update(&exp(0, 0, 0.5, 1, false));
768        let expected = 0.5 + 0.9 * learner.q_value(StateId(1), ActionId(2));
769        // We re-read after the update so assert via td_error magnitude.
770        assert!(td > 0.0);
771        let _ = expected; // used indirectly
772    }
773
774    #[test]
775    fn test_q_learning_multiple_updates_converge() {
776        let mut learner = ReinforcementLearner::new(
777            RlAlgorithm::QLearning {
778                alpha: 0.5,
779                gamma: 0.0,
780                epsilon: 0.0,
781            },
782            2,
783            1,
784        );
785        // With γ=0 and r=1 always, Q(s,a) should converge to 1.0.
786        for _ in 0..100 {
787            learner.update(&exp(0, 0, 1.0, 0, false));
788        }
789        let q = learner.q_value(StateId(0), ActionId(0));
790        assert!((q - 1.0).abs() < 0.01, "q={q}");
791    }
792
793    // -----------------------------------------------------------------------
794    // SARSA update
795    // -----------------------------------------------------------------------
796
797    #[test]
798    fn test_sarsa_update_increments_steps() {
799        let mut learner = make_sarsa();
800        learner.update(&exp(0, 0, 1.0, 1, false));
801        assert_eq!(learner.total_steps, 1);
802    }
803
804    #[test]
805    fn test_sarsa_terminal_step() {
806        let mut learner = make_sarsa();
807        let td = learner.update(&exp(0, 0, 2.0, 99, true));
808        assert!((td - 2.0).abs() < 1e-12);
809        assert!((learner.q_value(StateId(0), ActionId(0)) - 0.2).abs() < 1e-12);
810    }
811
812    #[test]
813    fn test_sarsa_non_terminal_on_policy() {
814        // SARSA selects next action on-policy; with all Q-values at zero the
815        // selected action doesn't matter — Q(s', a') == 0 regardless.
816        let mut learner = make_sarsa();
817        let td = learner.update(&exp(0, 1, 3.0, 2, false));
818        // δ = 3 + 0.9 * 0 - 0 = 3; Q(0,1) = 0.3
819        assert!((td - 3.0).abs() < 1e-12);
820        assert!((learner.q_value(StateId(0), ActionId(1)) - 0.3).abs() < 1e-12);
821    }
822
823    // -----------------------------------------------------------------------
824    // Double Q-learning
825    // -----------------------------------------------------------------------
826
827    #[test]
828    fn test_double_q_update_increments_steps() {
829        let mut learner = make_double_q();
830        learner.update(&exp(0, 0, 1.0, 1, false));
831        assert_eq!(learner.total_steps, 1);
832    }
833
834    #[test]
835    fn test_double_q_terminal_step_both_tables() {
836        // Run many updates to exercise both table-1 and table-2 paths.
837        let mut learner = make_double_q();
838        for _ in 0..200 {
839            learner.update(&exp(0, 0, 1.0, 99, true));
840        }
841        let q1 = learner.q_value(StateId(0), ActionId(0));
842        let q2 = learner.read_q2(StateId(0), ActionId(0));
843        // Both tables should be non-zero.
844        assert!(q1 > 0.0 || q2 > 0.0);
845    }
846
847    #[test]
848    fn test_double_q_both_tables_updated() {
849        let mut learner = make_double_q();
850        // Run enough updates to exercise both branches.
851        for i in 0u64..500 {
852            learner.update(&exp(0, 0, 1.0, 1, i == 499));
853        }
854        let q1 = learner.q_value(StateId(0), ActionId(0));
855        let q2 = learner.read_q2(StateId(0), ActionId(0));
856        assert!(q1 != 0.0, "q_table1 was never updated");
857        assert!(q2 != 0.0, "q_table2 was never updated");
858    }
859
860    // -----------------------------------------------------------------------
861    // Batch update
862    // -----------------------------------------------------------------------
863
864    #[test]
865    fn test_batch_update_returns_correct_length() {
866        let mut learner = make_q_learner();
867        let experiences: Vec<_> = (0..5).map(|i| exp(i, 0, 1.0, i + 1, false)).collect();
868        let errors = learner.batch_update(&experiences);
869        assert_eq!(errors.len(), 5);
870    }
871
872    #[test]
873    fn test_batch_update_increments_steps() {
874        let mut learner = make_q_learner();
875        let experiences: Vec<_> = (0..10).map(|i| exp(i, 0, 1.0, i + 1, false)).collect();
876        learner.batch_update(&experiences);
877        assert_eq!(learner.total_steps, 10);
878    }
879
880    #[test]
881    fn test_batch_update_empty_slice() {
882        let mut learner = make_q_learner();
883        let errors = learner.batch_update(&[]);
884        assert!(errors.is_empty());
885        assert_eq!(learner.total_steps, 0);
886    }
887
888    // -----------------------------------------------------------------------
889    // Action selection
890    // -----------------------------------------------------------------------
891
892    #[test]
893    fn test_select_greedy_action() {
894        let mut learner = make_q_learner();
895        // Force Q(0, 2) to be the highest by running a direct update.
896        learner.update(&exp(0, 2, 5.0, 1, true));
897        let action = learner.select_action(StateId(0), &Policy::Greedy);
898        assert_eq!(action, ActionId(2));
899    }
900
901    #[test]
902    fn test_select_random_action_in_range() {
903        let mut learner = make_q_learner();
904        for _ in 0..50 {
905            let a = learner.select_action(StateId(0), &Policy::Random);
906            assert!(
907                a.0 < learner.n_actions as u32,
908                "action out of range: {}",
909                a.0
910            );
911        }
912    }
913
914    #[test]
915    fn test_select_epsilon_greedy_explores() {
916        // With epsilon=1.0 every action should be random.
917        let mut learner = make_q_learner();
918        learner.update(&exp(0, 0, 100.0, 1, true)); // Q(0,0) is very high
919        let policy = Policy::EpsilonGreedy { epsilon: 1.0 };
920        let mut seen = std::collections::HashSet::new();
921        for _ in 0..200 {
922            let a = learner.select_action(StateId(0), &policy);
923            seen.insert(a.0);
924        }
925        // With 200 samples at epsilon=1.0 we expect to see all 4 actions.
926        assert!(seen.len() > 1);
927    }
928
929    #[test]
930    fn test_select_epsilon_greedy_zero_exploits() {
931        // With epsilon=0 we always exploit.
932        let mut learner = make_q_learner();
933        learner.update(&exp(0, 3, 100.0, 1, true));
934        let policy = Policy::EpsilonGreedy { epsilon: 0.0 };
935        for _ in 0..20 {
936            assert_eq!(learner.select_action(StateId(0), &policy), ActionId(3));
937        }
938    }
939
940    // -----------------------------------------------------------------------
941    // explored_states
942    // -----------------------------------------------------------------------
943
944    #[test]
945    fn test_explored_states_grows() {
946        let mut learner = make_q_learner();
947        assert_eq!(learner.explored_states(), 0);
948        learner.update(&exp(0, 0, 1.0, 1, false));
949        assert!(learner.explored_states() >= 1);
950        learner.update(&exp(5, 0, 1.0, 6, false));
951        assert!(learner.explored_states() >= 2);
952    }
953
954    #[test]
955    fn test_explored_states_no_duplicate() {
956        let mut learner = make_q_learner();
957        for _ in 0..100 {
958            learner.update(&exp(0, 0, 1.0, 1, false));
959        }
960        // Only state 0 (source) and state 1 (next) should have been initialised.
961        assert!(learner.explored_states() <= 2);
962    }
963
964    // -----------------------------------------------------------------------
965    // Episode management
966    // -----------------------------------------------------------------------
967
968    #[test]
969    fn test_start_episode_increments_count() {
970        let mut learner = make_q_learner();
971        learner.start_episode();
972        assert_eq!(learner.total_episodes, 1);
973        learner.start_episode();
974        assert_eq!(learner.total_episodes, 2);
975    }
976
977    #[test]
978    fn test_end_episode_records_return() {
979        let mut learner = make_q_learner();
980        learner.start_episode();
981        learner.end_episode(42.5);
982        assert_eq!(*learner.episode_returns.back().unwrap_or(&0.0), 42.5);
983    }
984
985    #[test]
986    fn test_avg_return_empty_returns_zero() {
987        let learner = make_q_learner();
988        assert_eq!(learner.avg_return(10), 0.0);
989    }
990
991    #[test]
992    fn test_avg_return_correct() {
993        let mut learner = make_q_learner();
994        for r in [1.0, 2.0, 3.0, 4.0, 5.0] {
995            learner.start_episode();
996            learner.end_episode(r);
997        }
998        let avg = learner.avg_return(5);
999        assert!((avg - 3.0).abs() < 1e-10, "avg={avg}");
1000    }
1001
1002    #[test]
1003    fn test_avg_return_last_n() {
1004        let mut learner = make_q_learner();
1005        for r in [1.0, 2.0, 3.0, 4.0, 5.0] {
1006            learner.start_episode();
1007            learner.end_episode(r);
1008        }
1009        // Last 3 returns: 3, 4, 5 → avg = 4
1010        let avg = learner.avg_return(3);
1011        assert!((avg - 4.0).abs() < 1e-10, "avg={avg}");
1012    }
1013
1014    #[test]
1015    fn test_episode_returns_capped_at_1000() {
1016        let mut learner = make_q_learner();
1017        for i in 0..1200 {
1018            learner.start_episode();
1019            learner.end_episode(i as f64);
1020        }
1021        assert!(learner.episode_returns.len() <= 1000);
1022    }
1023
1024    // -----------------------------------------------------------------------
1025    // Stats
1026    // -----------------------------------------------------------------------
1027
1028    #[test]
1029    fn test_stats_initial() {
1030        let learner = make_q_learner();
1031        let s = learner.stats();
1032        assert_eq!(s.total_steps, 0);
1033        assert_eq!(s.total_episodes, 0);
1034        assert_eq!(s.explored_states, 0);
1035        assert_eq!(s.avg_return_last_100, 0.0);
1036        assert_eq!(s.best_return, 0.0);
1037    }
1038
1039    #[test]
1040    fn test_stats_after_updates() {
1041        let mut learner = make_q_learner();
1042        learner.update(&exp(0, 0, 1.0, 1, false));
1043        learner.start_episode();
1044        learner.end_episode(10.0);
1045        let s = learner.stats();
1046        assert_eq!(s.total_steps, 1);
1047        assert_eq!(s.total_episodes, 1);
1048        assert!(s.explored_states > 0);
1049        assert!((s.best_return - 10.0).abs() < 1e-10);
1050    }
1051
1052    #[test]
1053    fn test_stats_best_return_tracks_max() {
1054        let mut learner = make_q_learner();
1055        for r in [5.0, 10.0, 3.0, 7.0] {
1056            learner.start_episode();
1057            learner.end_episode(r);
1058        }
1059        let s = learner.stats();
1060        assert!((s.best_return - 10.0).abs() < 1e-10);
1061    }
1062
1063    // -----------------------------------------------------------------------
1064    // RlError
1065    // -----------------------------------------------------------------------
1066
1067    #[test]
1068    fn test_rl_error_display_invalid_action() {
1069        let e = RlError::InvalidAction(5);
1070        let s = format!("{e}");
1071        assert!(s.contains('5'));
1072    }
1073
1074    #[test]
1075    fn test_rl_error_display_invalid_state() {
1076        let e = RlError::InvalidState;
1077        let s = format!("{e}");
1078        assert!(!s.is_empty());
1079    }
1080
1081    #[test]
1082    fn test_rl_error_is_std_error() {
1083        fn assert_error<E: std::error::Error>(_: &E) {}
1084        assert_error(&RlError::InvalidAction(0));
1085        assert_error(&RlError::InvalidState);
1086    }
1087
1088    // -----------------------------------------------------------------------
1089    // RlAlgorithm helpers
1090    // -----------------------------------------------------------------------
1091
1092    #[test]
1093    fn test_hyperparams_q_learning() {
1094        let algo = RlAlgorithm::QLearning {
1095            alpha: 0.1,
1096            gamma: 0.9,
1097            epsilon: 0.05,
1098        };
1099        let (a, g, e) = algo.hyperparams();
1100        assert!((a - 0.1).abs() < 1e-15);
1101        assert!((g - 0.9).abs() < 1e-15);
1102        assert!((e - 0.05).abs() < 1e-15);
1103    }
1104
1105    #[test]
1106    fn test_hyperparams_sarsa() {
1107        let algo = RlAlgorithm::Sarsa {
1108            alpha: 0.2,
1109            gamma: 0.95,
1110            epsilon: 0.1,
1111        };
1112        let (a, g, e) = algo.hyperparams();
1113        assert!((a - 0.2).abs() < 1e-15);
1114        assert!((g - 0.95).abs() < 1e-15);
1115        assert!((e - 0.1).abs() < 1e-15);
1116    }
1117
1118    #[test]
1119    fn test_hyperparams_double_q() {
1120        let algo = RlAlgorithm::DoubleQLearning {
1121            alpha: 0.3,
1122            gamma: 0.8,
1123            epsilon: 0.2,
1124        };
1125        let (a, g, _e) = algo.hyperparams();
1126        assert!((a - 0.3).abs() < 1e-15);
1127        assert!((g - 0.8).abs() < 1e-15);
1128    }
1129
1130    // -----------------------------------------------------------------------
1131    // Regression / convergence
1132    // -----------------------------------------------------------------------
1133
1134    #[test]
1135    fn test_q_learning_negative_reward() {
1136        let mut learner = make_q_learner();
1137        let td = learner.update(&exp(0, 0, -1.0, 1, true));
1138        assert!((td - (-1.0)).abs() < 1e-12);
1139        assert!((learner.q_value(StateId(0), ActionId(0)) - (-0.1)).abs() < 1e-12);
1140    }
1141
1142    #[test]
1143    fn test_sarsa_negative_reward() {
1144        let mut learner = make_sarsa();
1145        let td = learner.update(&exp(0, 0, -2.0, 1, true));
1146        assert!((td - (-2.0)).abs() < 1e-12);
1147    }
1148
1149    #[test]
1150    fn test_q_learning_long_episode() {
1151        let mut learner = ReinforcementLearner::new(
1152            RlAlgorithm::QLearning {
1153                alpha: 0.5,
1154                gamma: 0.0,
1155                epsilon: 0.0,
1156            },
1157            2,
1158            123,
1159        );
1160        learner.start_episode();
1161        let total: f64 = (0..20)
1162            .map(|i| {
1163                learner.update(&exp(0, 0, i as f64, 0, false));
1164                i as f64
1165            })
1166            .sum();
1167        learner.end_episode(total);
1168        assert_eq!(learner.total_steps, 20);
1169        assert!((learner.avg_return(1) - total).abs() < 1e-10);
1170    }
1171
1172    #[test]
1173    fn test_double_q_no_panic_on_zero_seed() {
1174        let mut learner = ReinforcementLearner::new(
1175            RlAlgorithm::DoubleQLearning {
1176                alpha: 0.1,
1177                gamma: 0.9,
1178                epsilon: 0.1,
1179            },
1180            3,
1181            0,
1182        );
1183        for i in 0..50u64 {
1184            learner.update(&exp(i % 5, 0, 1.0, (i + 1) % 5, false));
1185        }
1186        assert_eq!(learner.total_steps, 50);
1187    }
1188
1189    #[test]
1190    fn test_value_after_update() {
1191        let mut learner = make_q_learner();
1192        learner.update(&exp(7, 1, 3.0, 8, true));
1193        // Q(7, 1) = 0.3; V(7) = max over actions = 0.3
1194        let v = learner.value(StateId(7));
1195        assert!((v - 0.3).abs() < 1e-12, "v={v}");
1196    }
1197
1198    #[test]
1199    fn test_best_action_after_update() {
1200        let mut learner = make_q_learner();
1201        learner.update(&exp(0, 3, 10.0, 1, true)); // Q(0, 3) high
1202        assert_eq!(learner.best_action(StateId(0)), ActionId(3));
1203    }
1204
1205    /// StateId and ActionId should be copyable and comparable.
1206    #[test]
1207    fn test_identifiers_copy_eq() {
1208        let s1 = StateId(1);
1209        let s2 = s1;
1210        assert_eq!(s1, s2);
1211
1212        let a1 = ActionId(2);
1213        let a2 = a1;
1214        assert_eq!(a1, a2);
1215    }
1216}