1use burn::tensor::{Int, Tensor as BurnTensor, TensorData, backend::Backend};
58use rand::Rng;
59
60use super::sum_tree::SumTree;
61
62#[derive(Debug, Clone)]
67pub struct PrioritizedBatch {
68 pub observations: Vec<f32>,
70 pub actions: Vec<i64>,
72 pub rewards: Vec<f32>,
74 pub next_observations: Vec<f32>,
76 pub dones: Vec<bool>,
78 pub is_weights: Vec<f32>,
81 pub indices: Vec<usize>,
86 pub obs_dim: usize,
88}
89
90impl PrioritizedBatch {
91 pub fn len(&self) -> usize {
93 self.actions.len()
94 }
95
96 pub fn is_empty(&self) -> bool {
98 self.actions.is_empty()
99 }
100
101 pub fn to_burn_tensors<B: Backend>(&self, device: &B::Device) -> PrioritizedBurnTensors<B> {
119 let batch = self.len();
120 let obs_dim = self.obs_dim;
121
122 let observations = BurnTensor::<B, 2>::from_data(
126 TensorData::new(self.observations.clone(), [batch, obs_dim]),
127 device,
128 );
129 let next_observations = BurnTensor::<B, 2>::from_data(
130 TensorData::new(self.next_observations.clone(), [batch, obs_dim]),
131 device,
132 );
133 let actions = BurnTensor::<B, 1, Int>::from_data(
134 TensorData::new(self.actions.clone(), [batch]),
135 device,
136 );
137 let rewards =
138 BurnTensor::<B, 1>::from_data(TensorData::new(self.rewards.clone(), [batch]), device);
139 let dones_f: Vec<f32> = self.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
140 let dones = BurnTensor::<B, 1>::from_data(TensorData::new(dones_f, [batch]), device);
141 let is_weights = BurnTensor::<B, 1>::from_data(
142 TensorData::new(self.is_weights.clone(), [batch]),
143 device,
144 );
145
146 PrioritizedBurnTensors {
147 observations,
148 actions,
149 rewards,
150 next_observations,
151 dones,
152 is_weights,
153 }
154 }
155}
156
157#[derive(Debug)]
164pub struct PrioritizedBurnTensors<B: Backend> {
165 pub observations: BurnTensor<B, 2>,
167 pub actions: BurnTensor<B, 1, Int>,
169 pub rewards: BurnTensor<B, 1>,
171 pub next_observations: BurnTensor<B, 2>,
173 pub dones: BurnTensor<B, 1>,
175 pub is_weights: BurnTensor<B, 1>,
178}
179
180#[derive(Debug, Clone)]
192pub struct PrioritizedReplayBuffer {
193 obs_dim: usize,
194 capacity: usize,
195 len: usize,
196 write_idx: usize,
197
198 observations: Vec<f32>,
199 actions: Vec<i64>,
200 rewards: Vec<f32>,
201 next_observations: Vec<f32>,
202 dones: Vec<bool>,
203
204 tree: SumTree,
205 alpha: f32,
206 epsilon: f32,
207
208 max_priority: f32,
212}
213
214impl PrioritizedReplayBuffer {
215 pub fn new(capacity: usize, obs_dim: usize, alpha: f32, epsilon: f32) -> Self {
230 assert!(capacity > 0, "PrioritizedReplayBuffer capacity must be > 0");
231 assert!(obs_dim > 0, "PrioritizedReplayBuffer obs_dim must be > 0");
232 assert!(
233 (0.0..=1.0).contains(&alpha),
234 "PrioritizedReplayBuffer alpha must be in [0, 1], got {}",
235 alpha
236 );
237 assert!(epsilon >= 0.0, "PrioritizedReplayBuffer epsilon must be >= 0, got {}", epsilon);
238
239 Self {
240 obs_dim,
241 capacity,
242 len: 0,
243 write_idx: 0,
244 observations: vec![0.0; capacity * obs_dim],
245 actions: vec![0; capacity],
246 rewards: vec![0.0; capacity],
247 next_observations: vec![0.0; capacity * obs_dim],
248 dones: vec![false; capacity],
249 tree: SumTree::new(capacity),
250 alpha,
251 epsilon,
252 max_priority: 1.0,
253 }
254 }
255
256 pub fn push(&mut self, obs: &[f32], action: i64, reward: f32, next_obs: &[f32], done: bool) {
263 debug_assert_eq!(
264 obs.len(),
265 self.obs_dim,
266 "PrioritizedReplayBuffer::push: obs.len() ({}) != obs_dim ({})",
267 obs.len(),
268 self.obs_dim
269 );
270 debug_assert_eq!(
271 next_obs.len(),
272 self.obs_dim,
273 "PrioritizedReplayBuffer::push: next_obs.len() ({}) != obs_dim ({})",
274 next_obs.len(),
275 self.obs_dim
276 );
277
278 let start = self.write_idx * self.obs_dim;
279 let end = start + self.obs_dim;
280 self.observations[start..end].copy_from_slice(obs);
281 self.next_observations[start..end].copy_from_slice(next_obs);
282 self.actions[self.write_idx] = action;
283 self.rewards[self.write_idx] = reward;
284 self.dones[self.write_idx] = done;
285
286 self.tree.update(self.write_idx, self.max_priority);
289
290 self.write_idx = (self.write_idx + 1) % self.capacity;
291 if self.len < self.capacity {
292 self.len += 1;
293 }
294 }
295
296 pub fn len(&self) -> usize {
298 self.len
299 }
300
301 pub fn is_empty(&self) -> bool {
303 self.len == 0
304 }
305
306 pub fn capacity(&self) -> usize {
308 self.capacity
309 }
310
311 pub fn obs_dim(&self) -> usize {
313 self.obs_dim
314 }
315
316 pub fn alpha(&self) -> f32 {
318 self.alpha
319 }
320
321 pub fn epsilon(&self) -> f32 {
323 self.epsilon
324 }
325
326 pub fn max_priority(&self) -> f32 {
331 self.max_priority
332 }
333
334 pub fn is_ready(&self, min_size: usize) -> bool {
336 self.len >= min_size
337 }
338
339 pub fn sample<R: Rng>(&self, batch_size: usize, beta: f32, rng: &mut R) -> PrioritizedBatch {
363 assert!(!self.is_empty(), "PrioritizedReplayBuffer is empty; cannot sample");
364 assert!(batch_size > 0, "batch_size must be > 0");
365
366 let obs_dim = self.obs_dim;
367 let total = self.tree.total();
368 assert!(total > 0.0, "PrioritizedReplayBuffer::sample: tree total priority is zero");
369 let n = self.len as f32;
370 let segment = total / batch_size as f32;
371
372 let mut observations = vec![0.0f32; batch_size * obs_dim];
373 let mut next_observations = vec![0.0f32; batch_size * obs_dim];
374 let mut actions = Vec::with_capacity(batch_size);
375 let mut rewards = Vec::with_capacity(batch_size);
376 let mut dones = Vec::with_capacity(batch_size);
377 let mut indices = Vec::with_capacity(batch_size);
378 let mut raw_weights = vec![0.0f32; batch_size];
379
380 for k in 0..batch_size {
381 let lo = segment * k as f32;
382 let hi = segment * (k + 1) as f32;
383 let p = if hi > lo {
386 rng.random_range(lo..hi)
387 } else {
388 lo
389 };
390 let (leaf_idx, leaf_priority) = self.tree.find(p);
391
392 let pos = leaf_idx.min(self.len - 1);
397
398 let obs_slice = &mut observations[k * obs_dim..(k + 1) * obs_dim];
399 let next_slice = &mut next_observations[k * obs_dim..(k + 1) * obs_dim];
400 obs_slice.copy_from_slice(&self.observations[pos * obs_dim..(pos + 1) * obs_dim]);
401 next_slice.copy_from_slice(&self.next_observations[pos * obs_dim..(pos + 1) * obs_dim]);
402 actions.push(self.actions[pos]);
403 rewards.push(self.rewards[pos]);
404 dones.push(self.dones[pos]);
405 indices.push(leaf_idx);
406
407 let prob = leaf_priority.max(1e-12) / total;
410 raw_weights[k] = (1.0 / (n * prob)).powf(beta);
411 }
412
413 let max_w = raw_weights.iter().copied().fold(0.0f32, f32::max);
415 if max_w > 0.0 {
416 for w in raw_weights.iter_mut() {
417 *w /= max_w;
418 }
419 }
420
421 PrioritizedBatch {
422 observations,
423 actions,
424 rewards,
425 next_observations,
426 dones,
427 is_weights: raw_weights,
428 indices,
429 obs_dim,
430 }
431 }
432
433 pub fn update_priorities(&mut self, indices: &[usize], td_errors: &[f32]) {
443 assert_eq!(
444 indices.len(),
445 td_errors.len(),
446 "update_priorities: indices ({}) and td_errors ({}) length mismatch",
447 indices.len(),
448 td_errors.len(),
449 );
450
451 for (&idx, &td_err) in indices.iter().zip(td_errors.iter()) {
452 let magnitude = td_err.abs();
453 let priority = (magnitude + self.epsilon).powf(self.alpha);
455 let priority = if priority.is_finite() && priority >= 0.0 {
458 priority
459 } else {
460 0.0
461 };
462 self.tree.update(idx, priority);
463 if priority > self.max_priority {
464 self.max_priority = priority;
465 }
466 }
467 }
468
469 pub fn tree(&self) -> &SumTree {
471 &self.tree
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use std::collections::HashMap;
478
479 use rand::{SeedableRng, rngs::StdRng};
480
481 use super::*;
482
483 fn push_synth(buf: &mut PrioritizedReplayBuffer, n: usize) {
484 for i in 0..n {
485 let obs = [i as f32, i as f32 + 0.1];
486 let next_obs = [(i + 1) as f32, (i + 1) as f32 + 0.1];
487 buf.push(&obs, (i % 2) as i64, i as f32, &next_obs, i % 4 == 3);
488 }
489 }
490
491 #[test]
492 fn test_push_and_basic_accessors() {
493 let mut buf = PrioritizedReplayBuffer::new(8, 2, 0.6, 1e-6);
494 assert!(buf.is_empty());
495 assert_eq!(buf.capacity(), 8);
496 assert_eq!(buf.obs_dim(), 2);
497 assert!((buf.alpha() - 0.6).abs() < 1e-6);
498 assert!((buf.epsilon() - 1e-6).abs() < 1e-9);
499
500 push_synth(&mut buf, 5);
501 assert_eq!(buf.len(), 5);
502 assert!(!buf.is_empty());
503 assert!(buf.is_ready(3));
504 assert!(!buf.is_ready(6));
505 }
506
507 #[test]
508 fn test_push_uses_max_priority_for_new_transitions() {
509 let mut buf = PrioritizedReplayBuffer::new(4, 2, 0.6, 1e-6);
513 push_synth(&mut buf, 2);
514 buf.update_priorities(&[0], &[100.0]);
516 let bumped_max = buf.max_priority();
517 assert!(
518 bumped_max > 1.0,
519 "max_priority should rise after big TD error, got {}",
520 bumped_max
521 );
522
523 buf.push(&[9.0, 9.0], 0, 0.0, &[10.0, 10.0], false);
525 let new_leaf = buf.tree().leaf_priority(2);
526 assert!(
527 (new_leaf - bumped_max).abs() < 1e-4,
528 "new leaf priority {} != max_priority {}",
529 new_leaf,
530 bumped_max,
531 );
532 }
533
534 #[test]
535 fn test_sum_tree_round_trip() {
536 let n: usize = 4;
540 let mut buf = PrioritizedReplayBuffer::new(n, 1, 1.0, 0.0);
541 for i in 0..n {
542 buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
543 }
544 buf.update_priorities(&[0, 1, 2, 3], &[1.0, 2.0, 3.0, 4.0]);
547
548 let total: f32 = 1.0 + 2.0 + 3.0 + 4.0;
549 let expected: [f32; 4] = [1.0 / total, 2.0 / total, 3.0 / total, 4.0 / total];
550
551 let mut counts = vec![0usize; n];
552 let mut rng = StdRng::seed_from_u64(42);
553 let trials = 20_000;
554 for _ in 0..trials {
558 let batch = buf.sample(1, 0.4, &mut rng);
559 counts[batch.actions[0] as usize] += 1;
560 }
561
562 for i in 0..n {
563 let observed = counts[i] as f32 / trials as f32;
564 let diff = (observed - expected[i]).abs();
565 assert!(
566 diff < 0.02,
567 "leaf {} sampling freq off: expected {:.4}, observed {:.4} (diff {:.4})",
568 i,
569 expected[i],
570 observed,
571 diff,
572 );
573 }
574 }
575
576 #[test]
577 fn test_priority_update_zero_stops_sampling() {
578 let mut buf = PrioritizedReplayBuffer::new(4, 1, 1.0, 0.0);
582 for i in 0..4 {
583 buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
584 }
585 buf.update_priorities(&[0, 1, 2, 3], &[1.0, 1.0, 0.0, 1.0]);
587
588 let mut rng = StdRng::seed_from_u64(7);
589 let mut seen_two = 0usize;
590 for _ in 0..100 {
591 let batch = buf.sample(1, 0.4, &mut rng);
592 if batch.actions[0] == 2 {
593 seen_two += 1;
594 }
595 }
596 assert_eq!(seen_two, 0, "zero-priority transition should never be sampled");
597 }
598
599 #[test]
600 fn test_is_weight_normalization_to_max_one() {
601 let mut buf = PrioritizedReplayBuffer::new(8, 1, 0.6, 1e-6);
602 for i in 0..8 {
603 buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
604 }
605 buf.update_priorities(&[0, 1, 2, 3, 4, 5, 6, 7], &[0.1, 1.0, 2.0, 0.5, 5.0, 0.3, 0.7, 1.5]);
607
608 let mut rng = StdRng::seed_from_u64(11);
609 let batch = buf.sample(4, 0.4, &mut rng);
610 let max_w = batch.is_weights.iter().copied().fold(0.0f32, f32::max);
611 assert!(
612 (max_w - 1.0).abs() < 1e-6,
613 "max IS weight must be exactly 1.0 after normalization, got {}",
614 max_w
615 );
616 for w in &batch.is_weights {
617 assert!(*w > 0.0 && *w <= 1.0 + 1e-6, "IS weight out of (0, 1] range: {}", w);
618 }
619 }
620
621 #[test]
622 fn test_sample_indices_are_leaf_indices_round_trip_priorities() {
623 let mut buf = PrioritizedReplayBuffer::new(4, 1, 1.0, 0.0);
627 for i in 0..4 {
628 buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
629 }
630 buf.update_priorities(&[0, 1, 2, 3], &[1.0, 1.0, 1.0, 1.0]);
632
633 let mut rng = StdRng::seed_from_u64(99);
634 let batch = buf.sample(4, 0.4, &mut rng);
635
636 let new_errors: Vec<f32> = (0..batch.indices.len()).map(|k| 0.5 + k as f32).collect();
638 buf.update_priorities(&batch.indices, &new_errors);
639
640 let mut last_update: HashMap<usize, f32> = HashMap::new();
642 for (i, &idx) in batch.indices.iter().enumerate() {
643 last_update.insert(idx, (new_errors[i].abs() + 0.0_f32).powf(1.0));
644 }
645 for (&idx, &expected) in &last_update {
646 let stored = buf.tree().leaf_priority(idx);
647 assert!(
648 (stored - expected).abs() < 1e-5,
649 "leaf {} priority {} != expected {}",
650 idx,
651 stored,
652 expected,
653 );
654 }
655 }
656
657 #[test]
658 fn test_capacity_one_works() {
659 let mut buf = PrioritizedReplayBuffer::new(1, 2, 0.6, 1e-6);
661 buf.push(&[1.0, 2.0], 0, 0.5, &[3.0, 4.0], false);
662 assert_eq!(buf.len(), 1);
663
664 let mut rng = StdRng::seed_from_u64(0);
665 let batch = buf.sample(1, 0.4, &mut rng);
666 assert_eq!(batch.actions, vec![0]);
667 assert_eq!(batch.rewards, vec![0.5]);
668 assert_eq!(batch.indices, vec![0]);
669 assert!((batch.is_weights[0] - 1.0).abs() < 1e-6);
670 }
671
672 mod burn_tests {
673 use burn::backend::NdArray;
674
675 use super::*;
676
677 type B = NdArray<f32>;
678
679 #[test]
680 fn test_to_burn_tensors_shapes_and_roundtrip() {
681 let mut buf = PrioritizedReplayBuffer::new(8, 4, 0.6, 1e-6);
682 for i in 0..6 {
683 buf.push(&[i as f32; 4], (i % 2) as i64, i as f32, &[i as f32 + 1.0; 4], i == 5);
684 }
685 let mut rng = StdRng::seed_from_u64(1);
686 let batch = buf.sample(3, 0.4, &mut rng);
687 let device = crate::utils::cuda::default_burn_device::<B>();
688 let t = batch.to_burn_tensors::<B>(&device);
689
690 assert_eq!(t.observations.dims(), [3, 4]);
691 assert_eq!(t.next_observations.dims(), [3, 4]);
692 assert_eq!(t.actions.dims(), [3]);
693 assert_eq!(t.rewards.dims(), [3]);
694 assert_eq!(t.dones.dims(), [3]);
695 assert_eq!(t.is_weights.dims(), [3]);
696
697 let obs_flat: Vec<f32> = t.observations.into_data().to_vec().unwrap();
698 assert_eq!(obs_flat, batch.observations);
699 let next_flat: Vec<f32> = t.next_observations.into_data().to_vec().unwrap();
700 assert_eq!(next_flat, batch.next_observations);
701 let acts: Vec<i64> = t.actions.into_data().to_vec().unwrap();
702 assert_eq!(acts, batch.actions);
703 let rews: Vec<f32> = t.rewards.into_data().to_vec().unwrap();
704 assert_eq!(rews, batch.rewards);
705 let isw: Vec<f32> = t.is_weights.into_data().to_vec().unwrap();
706 assert_eq!(isw, batch.is_weights);
707 let dones_f: Vec<f32> = t.dones.into_data().to_vec().unwrap();
708 let expected_dones: Vec<f32> =
709 batch.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
710 assert_eq!(dones_f, expected_dones);
711 }
712
713 #[test]
714 fn test_to_burn_tensors_empty_batch_does_not_panic() {
715 let batch = PrioritizedBatch {
716 observations: vec![],
717 actions: vec![],
718 rewards: vec![],
719 next_observations: vec![],
720 dones: vec![],
721 is_weights: vec![],
722 indices: vec![],
723 obs_dim: 4,
724 };
725 let device = crate::utils::cuda::default_burn_device::<B>();
726 let t = batch.to_burn_tensors::<B>(&device);
727 assert_eq!(t.observations.dims(), [0, 4]);
728 assert_eq!(t.next_observations.dims(), [0, 4]);
729 assert_eq!(t.actions.dims(), [0]);
730 assert_eq!(t.rewards.dims(), [0]);
731 assert_eq!(t.dones.dims(), [0]);
732 assert_eq!(t.is_weights.dims(), [0]);
733 }
734 }
735
736 #[test]
737 fn test_ring_wraparound_evicts_oldest() {
738 let mut buf = PrioritizedReplayBuffer::new(4, 1, 1.0, 0.0);
741 for i in 0..6 {
742 buf.push(&[i as f32], i as i64, 0.0, &[(i + 100) as f32], false);
743 }
744 assert_eq!(buf.len(), 4);
745
746 let mut rng = StdRng::seed_from_u64(0);
747 let mut seen = std::collections::HashSet::new();
748 for _ in 0..200 {
749 let batch = buf.sample(1, 0.4, &mut rng);
750 seen.insert(batch.actions[0]);
751 }
752 assert!(!seen.contains(&0));
753 assert!(!seen.contains(&1));
754 }
755
756 #[test]
757 #[should_panic(expected = "capacity must be > 0")]
758 fn test_zero_capacity_panics() {
759 let _ = PrioritizedReplayBuffer::new(0, 2, 0.6, 1e-6);
760 }
761
762 #[test]
763 #[should_panic(expected = "alpha must be in")]
764 fn test_bad_alpha_panics() {
765 let _ = PrioritizedReplayBuffer::new(4, 2, 1.5, 1e-6);
766 }
767
768 #[test]
769 #[should_panic(expected = "epsilon must be")]
770 fn test_negative_epsilon_panics() {
771 let _ = PrioritizedReplayBuffer::new(4, 2, 0.6, -1.0);
772 }
773
774 #[test]
775 #[should_panic(expected = "length mismatch")]
776 fn test_update_length_mismatch_panics() {
777 let mut buf = PrioritizedReplayBuffer::new(4, 1, 0.6, 1e-6);
778 buf.push(&[0.0], 0, 0.0, &[1.0], false);
779 buf.update_priorities(&[0, 1], &[1.0]);
780 }
781}