use burn::tensor::{Int, Tensor as BurnTensor, TensorData, backend::Backend};
use rand::Rng;
use super::sum_tree::SumTree;
#[derive(Debug, Clone)]
pub struct PrioritizedBatch {
pub observations: Vec<f32>,
pub actions: Vec<i64>,
pub rewards: Vec<f32>,
pub next_observations: Vec<f32>,
pub dones: Vec<bool>,
pub is_weights: Vec<f32>,
pub indices: Vec<usize>,
pub obs_dim: usize,
}
impl PrioritizedBatch {
pub fn len(&self) -> usize {
self.actions.len()
}
pub fn is_empty(&self) -> bool {
self.actions.is_empty()
}
pub fn to_burn_tensors<B: Backend>(&self, device: &B::Device) -> PrioritizedBurnTensors<B> {
let batch = self.len();
let obs_dim = self.obs_dim;
let observations = BurnTensor::<B, 2>::from_data(
TensorData::new(self.observations.clone(), [batch, obs_dim]),
device,
);
let next_observations = BurnTensor::<B, 2>::from_data(
TensorData::new(self.next_observations.clone(), [batch, obs_dim]),
device,
);
let actions = BurnTensor::<B, 1, Int>::from_data(
TensorData::new(self.actions.clone(), [batch]),
device,
);
let rewards =
BurnTensor::<B, 1>::from_data(TensorData::new(self.rewards.clone(), [batch]), device);
let dones_f: Vec<f32> = self.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
let dones = BurnTensor::<B, 1>::from_data(TensorData::new(dones_f, [batch]), device);
let is_weights = BurnTensor::<B, 1>::from_data(
TensorData::new(self.is_weights.clone(), [batch]),
device,
);
PrioritizedBurnTensors {
observations,
actions,
rewards,
next_observations,
dones,
is_weights,
}
}
}
#[derive(Debug)]
pub struct PrioritizedBurnTensors<B: Backend> {
pub observations: BurnTensor<B, 2>,
pub actions: BurnTensor<B, 1, Int>,
pub rewards: BurnTensor<B, 1>,
pub next_observations: BurnTensor<B, 2>,
pub dones: BurnTensor<B, 1>,
pub is_weights: BurnTensor<B, 1>,
}
#[derive(Debug, Clone)]
pub struct PrioritizedReplayBuffer {
obs_dim: usize,
capacity: usize,
len: usize,
write_idx: usize,
observations: Vec<f32>,
actions: Vec<i64>,
rewards: Vec<f32>,
next_observations: Vec<f32>,
dones: Vec<bool>,
tree: SumTree,
alpha: f32,
epsilon: f32,
max_priority: f32,
}
impl PrioritizedReplayBuffer {
pub fn new(capacity: usize, obs_dim: usize, alpha: f32, epsilon: f32) -> Self {
assert!(capacity > 0, "PrioritizedReplayBuffer capacity must be > 0");
assert!(obs_dim > 0, "PrioritizedReplayBuffer obs_dim must be > 0");
assert!(
(0.0..=1.0).contains(&alpha),
"PrioritizedReplayBuffer alpha must be in [0, 1], got {}",
alpha
);
assert!(epsilon >= 0.0, "PrioritizedReplayBuffer epsilon must be >= 0, got {}", epsilon);
Self {
obs_dim,
capacity,
len: 0,
write_idx: 0,
observations: vec![0.0; capacity * obs_dim],
actions: vec![0; capacity],
rewards: vec![0.0; capacity],
next_observations: vec![0.0; capacity * obs_dim],
dones: vec![false; capacity],
tree: SumTree::new(capacity),
alpha,
epsilon,
max_priority: 1.0,
}
}
pub fn push(&mut self, obs: &[f32], action: i64, reward: f32, next_obs: &[f32], done: bool) {
debug_assert_eq!(
obs.len(),
self.obs_dim,
"PrioritizedReplayBuffer::push: obs.len() ({}) != obs_dim ({})",
obs.len(),
self.obs_dim
);
debug_assert_eq!(
next_obs.len(),
self.obs_dim,
"PrioritizedReplayBuffer::push: next_obs.len() ({}) != obs_dim ({})",
next_obs.len(),
self.obs_dim
);
let start = self.write_idx * self.obs_dim;
let end = start + self.obs_dim;
self.observations[start..end].copy_from_slice(obs);
self.next_observations[start..end].copy_from_slice(next_obs);
self.actions[self.write_idx] = action;
self.rewards[self.write_idx] = reward;
self.dones[self.write_idx] = done;
self.tree.update(self.write_idx, self.max_priority);
self.write_idx = (self.write_idx + 1) % self.capacity;
if self.len < self.capacity {
self.len += 1;
}
}
pub fn len(&self) -> usize {
self.len
}
pub fn is_empty(&self) -> bool {
self.len == 0
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn obs_dim(&self) -> usize {
self.obs_dim
}
pub fn alpha(&self) -> f32 {
self.alpha
}
pub fn epsilon(&self) -> f32 {
self.epsilon
}
pub fn max_priority(&self) -> f32 {
self.max_priority
}
pub fn is_ready(&self, min_size: usize) -> bool {
self.len >= min_size
}
pub fn sample<R: Rng>(&self, batch_size: usize, beta: f32, rng: &mut R) -> PrioritizedBatch {
assert!(!self.is_empty(), "PrioritizedReplayBuffer is empty; cannot sample");
assert!(batch_size > 0, "batch_size must be > 0");
let obs_dim = self.obs_dim;
let total = self.tree.total();
assert!(total > 0.0, "PrioritizedReplayBuffer::sample: tree total priority is zero");
let n = self.len as f32;
let segment = total / batch_size as f32;
let mut observations = vec![0.0f32; batch_size * obs_dim];
let mut next_observations = vec![0.0f32; batch_size * obs_dim];
let mut actions = Vec::with_capacity(batch_size);
let mut rewards = Vec::with_capacity(batch_size);
let mut dones = Vec::with_capacity(batch_size);
let mut indices = Vec::with_capacity(batch_size);
let mut raw_weights = vec![0.0f32; batch_size];
for k in 0..batch_size {
let lo = segment * k as f32;
let hi = segment * (k + 1) as f32;
let p = if hi > lo {
rng.random_range(lo..hi)
} else {
lo
};
let (leaf_idx, leaf_priority) = self.tree.find(p);
let pos = leaf_idx.min(self.len - 1);
let obs_slice = &mut observations[k * obs_dim..(k + 1) * obs_dim];
let next_slice = &mut next_observations[k * obs_dim..(k + 1) * obs_dim];
obs_slice.copy_from_slice(&self.observations[pos * obs_dim..(pos + 1) * obs_dim]);
next_slice.copy_from_slice(&self.next_observations[pos * obs_dim..(pos + 1) * obs_dim]);
actions.push(self.actions[pos]);
rewards.push(self.rewards[pos]);
dones.push(self.dones[pos]);
indices.push(leaf_idx);
let prob = leaf_priority.max(1e-12) / total;
raw_weights[k] = (1.0 / (n * prob)).powf(beta);
}
let max_w = raw_weights.iter().copied().fold(0.0f32, f32::max);
if max_w > 0.0 {
for w in raw_weights.iter_mut() {
*w /= max_w;
}
}
PrioritizedBatch {
observations,
actions,
rewards,
next_observations,
dones,
is_weights: raw_weights,
indices,
obs_dim,
}
}
pub fn update_priorities(&mut self, indices: &[usize], td_errors: &[f32]) {
assert_eq!(
indices.len(),
td_errors.len(),
"update_priorities: indices ({}) and td_errors ({}) length mismatch",
indices.len(),
td_errors.len(),
);
for (&idx, &td_err) in indices.iter().zip(td_errors.iter()) {
let magnitude = td_err.abs();
let priority = (magnitude + self.epsilon).powf(self.alpha);
let priority = if priority.is_finite() && priority >= 0.0 {
priority
} else {
0.0
};
self.tree.update(idx, priority);
if priority > self.max_priority {
self.max_priority = priority;
}
}
}
pub fn tree(&self) -> &SumTree {
&self.tree
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use rand::{SeedableRng, rngs::StdRng};
use super::*;
fn push_synth(buf: &mut PrioritizedReplayBuffer, n: usize) {
for i in 0..n {
let obs = [i as f32, i as f32 + 0.1];
let next_obs = [(i + 1) as f32, (i + 1) as f32 + 0.1];
buf.push(&obs, (i % 2) as i64, i as f32, &next_obs, i % 4 == 3);
}
}
#[test]
fn test_push_and_basic_accessors() {
let mut buf = PrioritizedReplayBuffer::new(8, 2, 0.6, 1e-6);
assert!(buf.is_empty());
assert_eq!(buf.capacity(), 8);
assert_eq!(buf.obs_dim(), 2);
assert!((buf.alpha() - 0.6).abs() < 1e-6);
assert!((buf.epsilon() - 1e-6).abs() < 1e-9);
push_synth(&mut buf, 5);
assert_eq!(buf.len(), 5);
assert!(!buf.is_empty());
assert!(buf.is_ready(3));
assert!(!buf.is_ready(6));
}
#[test]
fn test_push_uses_max_priority_for_new_transitions() {
let mut buf = PrioritizedReplayBuffer::new(4, 2, 0.6, 1e-6);
push_synth(&mut buf, 2);
buf.update_priorities(&[0], &[100.0]);
let bumped_max = buf.max_priority();
assert!(
bumped_max > 1.0,
"max_priority should rise after big TD error, got {}",
bumped_max
);
buf.push(&[9.0, 9.0], 0, 0.0, &[10.0, 10.0], false);
let new_leaf = buf.tree().leaf_priority(2);
assert!(
(new_leaf - bumped_max).abs() < 1e-4,
"new leaf priority {} != max_priority {}",
new_leaf,
bumped_max,
);
}
#[test]
fn test_sum_tree_round_trip() {
let n: usize = 4;
let mut buf = PrioritizedReplayBuffer::new(n, 1, 1.0, 0.0);
for i in 0..n {
buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
}
buf.update_priorities(&[0, 1, 2, 3], &[1.0, 2.0, 3.0, 4.0]);
let total: f32 = 1.0 + 2.0 + 3.0 + 4.0;
let expected: [f32; 4] = [1.0 / total, 2.0 / total, 3.0 / total, 4.0 / total];
let mut counts = vec![0usize; n];
let mut rng = StdRng::seed_from_u64(42);
let trials = 20_000;
for _ in 0..trials {
let batch = buf.sample(1, 0.4, &mut rng);
counts[batch.actions[0] as usize] += 1;
}
for i in 0..n {
let observed = counts[i] as f32 / trials as f32;
let diff = (observed - expected[i]).abs();
assert!(
diff < 0.02,
"leaf {} sampling freq off: expected {:.4}, observed {:.4} (diff {:.4})",
i,
expected[i],
observed,
diff,
);
}
}
#[test]
fn test_priority_update_zero_stops_sampling() {
let mut buf = PrioritizedReplayBuffer::new(4, 1, 1.0, 0.0);
for i in 0..4 {
buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
}
buf.update_priorities(&[0, 1, 2, 3], &[1.0, 1.0, 0.0, 1.0]);
let mut rng = StdRng::seed_from_u64(7);
let mut seen_two = 0usize;
for _ in 0..100 {
let batch = buf.sample(1, 0.4, &mut rng);
if batch.actions[0] == 2 {
seen_two += 1;
}
}
assert_eq!(seen_two, 0, "zero-priority transition should never be sampled");
}
#[test]
fn test_is_weight_normalization_to_max_one() {
let mut buf = PrioritizedReplayBuffer::new(8, 1, 0.6, 1e-6);
for i in 0..8 {
buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
}
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]);
let mut rng = StdRng::seed_from_u64(11);
let batch = buf.sample(4, 0.4, &mut rng);
let max_w = batch.is_weights.iter().copied().fold(0.0f32, f32::max);
assert!(
(max_w - 1.0).abs() < 1e-6,
"max IS weight must be exactly 1.0 after normalization, got {}",
max_w
);
for w in &batch.is_weights {
assert!(*w > 0.0 && *w <= 1.0 + 1e-6, "IS weight out of (0, 1] range: {}", w);
}
}
#[test]
fn test_sample_indices_are_leaf_indices_round_trip_priorities() {
let mut buf = PrioritizedReplayBuffer::new(4, 1, 1.0, 0.0);
for i in 0..4 {
buf.push(&[i as f32], i as i64, 0.0, &[(i + 1) as f32], false);
}
buf.update_priorities(&[0, 1, 2, 3], &[1.0, 1.0, 1.0, 1.0]);
let mut rng = StdRng::seed_from_u64(99);
let batch = buf.sample(4, 0.4, &mut rng);
let new_errors: Vec<f32> = (0..batch.indices.len()).map(|k| 0.5 + k as f32).collect();
buf.update_priorities(&batch.indices, &new_errors);
let mut last_update: HashMap<usize, f32> = HashMap::new();
for (i, &idx) in batch.indices.iter().enumerate() {
last_update.insert(idx, (new_errors[i].abs() + 0.0_f32).powf(1.0));
}
for (&idx, &expected) in &last_update {
let stored = buf.tree().leaf_priority(idx);
assert!(
(stored - expected).abs() < 1e-5,
"leaf {} priority {} != expected {}",
idx,
stored,
expected,
);
}
}
#[test]
fn test_capacity_one_works() {
let mut buf = PrioritizedReplayBuffer::new(1, 2, 0.6, 1e-6);
buf.push(&[1.0, 2.0], 0, 0.5, &[3.0, 4.0], false);
assert_eq!(buf.len(), 1);
let mut rng = StdRng::seed_from_u64(0);
let batch = buf.sample(1, 0.4, &mut rng);
assert_eq!(batch.actions, vec![0]);
assert_eq!(batch.rewards, vec![0.5]);
assert_eq!(batch.indices, vec![0]);
assert!((batch.is_weights[0] - 1.0).abs() < 1e-6);
}
mod burn_tests {
use burn::backend::NdArray;
use super::*;
type B = NdArray<f32>;
#[test]
fn test_to_burn_tensors_shapes_and_roundtrip() {
let mut buf = PrioritizedReplayBuffer::new(8, 4, 0.6, 1e-6);
for i in 0..6 {
buf.push(&[i as f32; 4], (i % 2) as i64, i as f32, &[i as f32 + 1.0; 4], i == 5);
}
let mut rng = StdRng::seed_from_u64(1);
let batch = buf.sample(3, 0.4, &mut rng);
let device = crate::utils::cuda::default_burn_device::<B>();
let t = batch.to_burn_tensors::<B>(&device);
assert_eq!(t.observations.dims(), [3, 4]);
assert_eq!(t.next_observations.dims(), [3, 4]);
assert_eq!(t.actions.dims(), [3]);
assert_eq!(t.rewards.dims(), [3]);
assert_eq!(t.dones.dims(), [3]);
assert_eq!(t.is_weights.dims(), [3]);
let obs_flat: Vec<f32> = t.observations.into_data().to_vec().unwrap();
assert_eq!(obs_flat, batch.observations);
let next_flat: Vec<f32> = t.next_observations.into_data().to_vec().unwrap();
assert_eq!(next_flat, batch.next_observations);
let acts: Vec<i64> = t.actions.into_data().to_vec().unwrap();
assert_eq!(acts, batch.actions);
let rews: Vec<f32> = t.rewards.into_data().to_vec().unwrap();
assert_eq!(rews, batch.rewards);
let isw: Vec<f32> = t.is_weights.into_data().to_vec().unwrap();
assert_eq!(isw, batch.is_weights);
let dones_f: Vec<f32> = t.dones.into_data().to_vec().unwrap();
let expected_dones: Vec<f32> =
batch.dones.iter().map(|&d| if d { 1.0 } else { 0.0 }).collect();
assert_eq!(dones_f, expected_dones);
}
#[test]
fn test_to_burn_tensors_empty_batch_does_not_panic() {
let batch = PrioritizedBatch {
observations: vec![],
actions: vec![],
rewards: vec![],
next_observations: vec![],
dones: vec![],
is_weights: vec![],
indices: vec![],
obs_dim: 4,
};
let device = crate::utils::cuda::default_burn_device::<B>();
let t = batch.to_burn_tensors::<B>(&device);
assert_eq!(t.observations.dims(), [0, 4]);
assert_eq!(t.next_observations.dims(), [0, 4]);
assert_eq!(t.actions.dims(), [0]);
assert_eq!(t.rewards.dims(), [0]);
assert_eq!(t.dones.dims(), [0]);
assert_eq!(t.is_weights.dims(), [0]);
}
}
#[test]
fn test_ring_wraparound_evicts_oldest() {
let mut buf = PrioritizedReplayBuffer::new(4, 1, 1.0, 0.0);
for i in 0..6 {
buf.push(&[i as f32], i as i64, 0.0, &[(i + 100) as f32], false);
}
assert_eq!(buf.len(), 4);
let mut rng = StdRng::seed_from_u64(0);
let mut seen = std::collections::HashSet::new();
for _ in 0..200 {
let batch = buf.sample(1, 0.4, &mut rng);
seen.insert(batch.actions[0]);
}
assert!(!seen.contains(&0));
assert!(!seen.contains(&1));
}
#[test]
#[should_panic(expected = "capacity must be > 0")]
fn test_zero_capacity_panics() {
let _ = PrioritizedReplayBuffer::new(0, 2, 0.6, 1e-6);
}
#[test]
#[should_panic(expected = "alpha must be in")]
fn test_bad_alpha_panics() {
let _ = PrioritizedReplayBuffer::new(4, 2, 1.5, 1e-6);
}
#[test]
#[should_panic(expected = "epsilon must be")]
fn test_negative_epsilon_panics() {
let _ = PrioritizedReplayBuffer::new(4, 2, 0.6, -1.0);
}
#[test]
#[should_panic(expected = "length mismatch")]
fn test_update_length_mismatch_panics() {
let mut buf = PrioritizedReplayBuffer::new(4, 1, 0.6, 1e-6);
buf.push(&[0.0], 0, 0.0, &[1.0], false);
buf.update_priorities(&[0, 1], &[1.0]);
}
}