1use std::collections::{HashMap, VecDeque};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct StateId(pub u64);
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct ActionId(pub u32);
26
27#[derive(Debug, Clone, PartialEq)]
33pub enum RlAlgorithm {
34 QLearning {
36 alpha: f64,
38 gamma: f64,
40 epsilon: f64,
42 },
43 Sarsa {
45 alpha: f64,
47 gamma: f64,
49 epsilon: f64,
51 },
52 DoubleQLearning {
56 alpha: f64,
58 gamma: f64,
60 epsilon: f64,
62 },
63}
64
65impl RlAlgorithm {
66 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#[derive(Debug, Clone)]
94pub struct Experience {
95 pub state: StateId,
97 pub action: ActionId,
99 pub reward: f64,
101 pub next_state: StateId,
103 pub done: bool,
105}
106
107#[derive(Debug, Clone, PartialEq)]
113pub enum Policy {
114 EpsilonGreedy { epsilon: f64 },
117 Greedy,
119 Random,
121}
122
123#[derive(Debug, Clone, PartialEq)]
129pub struct RlStats {
130 pub total_steps: u64,
132 pub total_episodes: u64,
134 pub explored_states: usize,
136 pub avg_return_last_100: f64,
138 pub best_return: f64,
140}
141
142#[derive(Debug, Clone, PartialEq)]
148pub enum RlError {
149 InvalidAction(u32),
151 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#[inline]
175fn xorshift64(state: &mut u64) -> u64 {
176 *state ^= *state << 13;
177 *state ^= *state >> 7;
178 *state ^= *state << 17;
179 *state
180}
181
182#[inline]
184fn rand_f64(state: &mut u64) -> f64 {
185 let bits = xorshift64(state);
187 (bits >> 11) as f64 / (1u64 << 53) as f64
188}
189
190#[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 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#[derive(Debug, Clone)]
217pub struct ReinforcementLearner {
218 pub algorithm: RlAlgorithm,
220 pub q_table: HashMap<StateId, Vec<f64>>,
222 pub q_table2: HashMap<StateId, Vec<f64>>,
224 pub n_actions: usize,
226 pub total_steps: u64,
228 pub total_episodes: u64,
231 pub episode_returns: VecDeque<f64>,
233 pub rng_state: u64,
235}
236
237impl ReinforcementLearner {
238 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 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 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 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 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 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 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 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 pub fn select_action(&mut self, state: StateId, policy: &Policy) -> ActionId {
354 match policy {
355 Policy::Greedy => {
356 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 self.ensure_state(state);
372 self.argmax_q(state)
373 }
374 }
375 }
376 }
377
378 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 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 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 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 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 fn update_double_q(&mut self, exp: &Experience, alpha: f64, gamma: f64) -> f64 {
456 let coin = xorshift64(&mut self.rng_state) & 1;
458
459 let td_error = if coin == 0 {
460 let q_sa = self.read_q(exp.state, exp.action);
462 let target = if exp.done {
463 exp.reward
464 } else {
465 let a_star = self.argmax_q(exp.next_state);
467 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 let q_sa2 = self.read_q2(exp.state, exp.action);
481 let target = if exp.done {
482 exp.reward
483 } else {
484 let a_star = self.argmax_q_sum(exp.next_state); 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 pub fn batch_update(&mut self, experiences: &[Experience]) -> Vec<f64> {
509 experiences.iter().map(|exp| self.update(exp)).collect()
510 }
511
512 pub fn best_action(&self, state: StateId) -> ActionId {
519 self.argmax_q(state)
520 }
521
522 pub fn q_value(&self, state: StateId, action: ActionId) -> f64 {
524 self.read_q(state, action)
525 }
526
527 pub fn value(&self, state: StateId) -> f64 {
529 self.max_q(state)
530 }
531
532 pub fn explored_states(&self) -> usize {
534 self.q_table.len()
535 }
536
537 pub fn start_episode(&mut self) {
545 self.episode_returns.push_back(0.0);
546 self.total_episodes += 1;
547 }
548
549 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 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 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 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#[cfg(test)]
609mod tests {
610 use super::{
611 ActionId, Experience, Policy, ReinforcementLearner, RlAlgorithm, RlError, StateId,
612 };
613
614 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 #[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 #[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 #[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 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 let mut learner = make_q_learner();
753 let td = learner.update(&exp(0, 1, 5.0, 99, true));
754 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 learner.update(&exp(1, 2, 10.0, 2, true)); 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 assert!(td > 0.0);
771 let _ = expected; }
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 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 #[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 let mut learner = make_sarsa();
817 let td = learner.update(&exp(0, 1, 3.0, 2, false));
818 assert!((td - 3.0).abs() < 1e-12);
820 assert!((learner.q_value(StateId(0), ActionId(1)) - 0.3).abs() < 1e-12);
821 }
822
823 #[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 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 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 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 #[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 #[test]
893 fn test_select_greedy_action() {
894 let mut learner = make_q_learner();
895 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 let mut learner = make_q_learner();
918 learner.update(&exp(0, 0, 100.0, 1, true)); 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 assert!(seen.len() > 1);
927 }
928
929 #[test]
930 fn test_select_epsilon_greedy_zero_exploits() {
931 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 #[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 assert!(learner.explored_states() <= 2);
962 }
963
964 #[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 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 #[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 #[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 #[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 #[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 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)); assert_eq!(learner.best_action(StateId(0)), ActionId(3));
1203 }
1204
1205 #[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}