Skip to main content

thrust_rl/buffer/replay/
prioritized.rs

1//! Prioritized Experience Replay (PER) buffer.
2//!
3//! This module adds a *prioritized* counterpart to
4//! [`super::ReplayBuffer`]. Transitions are stored in the same flat
5//! `Vec<f32>` layout (so the buffer stays WASM-compatible) but sampling
6//! is biased toward transitions with large TD error — those are the ones
7//! the Q-network has the most to learn from.
8//!
9//! # Algorithm (Schaul et al., 2015)
10//!
11//! Each transition `i` carries a scalar **priority** `pᵢ` that the
12//! buffer maintains in a [`SumTree`]. The sampling probability is
13//!
14//! ```text
15//! P(i) = pᵢ^α / Σⱼ pⱼ^α
16//! ```
17//!
18//! where `α` controls how sharply we bias toward high-priority
19//! transitions (`α = 0` recovers uniform sampling; `α = 1` is fully
20//! proportional). Because biased sampling skews the Q-update's
21//! expectation, each transition's loss is reweighted by an
22//! **importance-sampling (IS) weight**:
23//!
24//! ```text
25//! wᵢ = (1 / (N · P(i)))^β
26//! ```
27//!
28//! with `β` annealed from a small value (e.g. `0.4`) to `1.0` over
29//! training so the bias correction kicks in fully only once the
30//! Q-function has stabilized. By convention `wᵢ` is normalized by
31//! `max wⱼ` across the batch so the loss magnitude is comparable to the
32//! uniform-replay case (Schaul §3.4).
33//!
34//! In this implementation the actual priority stored in the tree is
35//! `pᵢ = (|TD_error_i| + ε)^α`, so the sum-tree's "weight" of leaf `i`
36//! already equals `pᵢ^α` and `P(i) = treeᵢ / total`. The `α` field is
37//! kept on the buffer purely so [`Self::push`] / [`Self::update_priorities`]
38//! can fold it back into newly written priorities.
39//!
40//! # Storage
41//!
42//! For a buffer of capacity `C` and observation dimension `D`:
43//! - `observations`: `Vec<f32>` of length `C * D` (flattened)
44//! - `actions`: `Vec<i64>` of length `C`
45//! - `rewards`: `Vec<f32>` of length `C`
46//! - `next_observations`: `Vec<f32>` of length `C * D` (flattened)
47//! - `dones`: `Vec<bool>` of length `C`
48//! - `tree`: a [`SumTree`] with `C` leaves, where leaf `i` corresponds 1:1 to
49//!   transition slot `i`. (We never permute slots, so leaf index and transition
50//!   position are the same value — but the API distinguishes them for clarity
51//!   and so a future "non-contiguous leaves" layout doesn't break callers.)
52//!
53//! # References
54//!
55//! - Schaul et al., *Prioritized Experience Replay* ([ICLR 2016](https://arxiv.org/abs/1511.05952)).
56
57use burn::tensor::{Int, Tensor as BurnTensor, TensorData, backend::Backend};
58use rand::Rng;
59
60use super::sum_tree::SumTree;
61
62/// One minibatch sampled from a [`PrioritizedReplayBuffer`].
63///
64/// All fields are CPU-side primitive vectors; convert to Burn tensors
65/// via [`PrioritizedBatch::to_burn_tensors`] when handing them to the trainer.
66#[derive(Debug, Clone)]
67pub struct PrioritizedBatch {
68    /// Flattened current observations, shape `[batch_size * obs_dim]`.
69    pub observations: Vec<f32>,
70    /// Actions taken, length `batch_size`.
71    pub actions: Vec<i64>,
72    /// Rewards received, length `batch_size`.
73    pub rewards: Vec<f32>,
74    /// Flattened next observations, shape `[batch_size * obs_dim]`.
75    pub next_observations: Vec<f32>,
76    /// Episode-end mask, length `batch_size`.
77    pub dones: Vec<bool>,
78    /// Per-sample importance-sampling weights, length `batch_size`,
79    /// normalized so `max(weights) == 1.0`.
80    pub is_weights: Vec<f32>,
81    /// Sum-tree leaf indices for the sampled transitions, length
82    /// `batch_size`. Pass these back into
83    /// [`PrioritizedReplayBuffer::update_priorities`] alongside the new
84    /// TD errors.
85    pub indices: Vec<usize>,
86    /// Length of one observation slice.
87    pub obs_dim: usize,
88}
89
90impl PrioritizedBatch {
91    /// Number of transitions in the batch.
92    pub fn len(&self) -> usize {
93        self.actions.len()
94    }
95
96    /// `true` if the batch is empty.
97    pub fn is_empty(&self) -> bool {
98        self.actions.is_empty()
99    }
100
101    /// Stack the batch into Burn tensors on `device`.
102    ///
103    /// Returns a named [`PrioritizedBurnTensors`] struct so the DQN
104    /// trainer can grab fields by name. Includes the importance-sampling
105    /// weights that the uniform [`super::ReplayBatch`] does not carry.
106    ///
107    /// Shapes (all on `device`):
108    /// - `observations`: `[batch, obs_dim]`, `f32`
109    /// - `actions`: `[batch]`, `i64`
110    /// - `rewards`: `[batch]`, `f32`
111    /// - `next_observations`: `[batch, obs_dim]`, `f32`
112    /// - `dones`: `[batch]`, `f32` (0.0 / 1.0)
113    /// - `is_weights`: `[batch]`, `f32`
114    ///
115    /// Note: `indices` is *not* a tensor — leaf-index round-trips back
116    /// into [`PrioritizedReplayBuffer::update_priorities`] as a host
117    /// `&[usize]`, so it stays on `self`.
118    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        // Direct rank-2 construction (vs. rank-1 + reshape) to keep the
123        // empty-batch case panic-free; the reshape path trips an internal
124        // shape assertion in cubecl-zspace when both dims are zero.
125        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/// Bundle of Burn tensors produced by [`PrioritizedBatch::to_burn_tensors`].
158///
159/// Adds the per-sample importance-sampling weights on top of the
160/// `ReplayBurnTensors` field set. The trainer multiplies the per-row
161/// TD-error magnitude by `is_weights` before reducing to a scalar loss
162/// — this is the bias correction described in Schaul et al. §3.4.
163#[derive(Debug)]
164pub struct PrioritizedBurnTensors<B: Backend> {
165    /// Observations, shape `[batch, obs_dim]`, dtype `f32`.
166    pub observations: BurnTensor<B, 2>,
167    /// Discrete actions, shape `[batch]`, dtype `i64`.
168    pub actions: BurnTensor<B, 1, Int>,
169    /// Rewards, shape `[batch]`, dtype `f32`.
170    pub rewards: BurnTensor<B, 1>,
171    /// Bootstrap-state observations, shape `[batch, obs_dim]`, dtype `f32`.
172    pub next_observations: BurnTensor<B, 2>,
173    /// Episode-terminal mask (0.0 or 1.0), shape `[batch]`, dtype `f32`.
174    pub dones: BurnTensor<B, 1>,
175    /// Importance-sampling weights, shape `[batch]`, dtype `f32`,
176    /// normalized so the max element is `1.0`.
177    pub is_weights: BurnTensor<B, 1>,
178}
179
180/// Fixed-capacity prioritized replay buffer.
181///
182/// Stores `(obs, action, reward, next_obs, done)` transitions in a ring
183/// buffer (same FIFO eviction as [`super::ReplayBuffer`]), plus a
184/// per-transition priority in a [`SumTree`] for O(log N) proportional
185/// sampling.
186///
187/// New transitions are pushed with the **maximum** existing priority
188/// (or `1.0` for an empty buffer), guaranteeing each fresh transition is
189/// sampled at least once before its priority can be updated by a TD
190/// error.
191#[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    /// Tracks the largest priority seen so far. New transitions inherit
209    /// this so they're guaranteed to be sampled at least once.
210    /// Starts at `1.0` so the first transition has a non-zero priority.
211    max_priority: f32,
212}
213
214impl PrioritizedReplayBuffer {
215    /// Create a new prioritized replay buffer.
216    ///
217    /// # Arguments
218    /// * `capacity` - Maximum number of transitions stored. Must be > 0.
219    /// * `obs_dim` - Length of one observation vector. Must be > 0.
220    /// * `alpha` - Priority exponent `α ∈ [0, 1]`. `0` recovers uniform
221    ///   sampling; `1` is fully proportional. Typical value `0.6`.
222    /// * `epsilon` - Tiny constant added to `|TD error|` before raising to `α`,
223    ///   so transitions with zero TD error still have a tiny chance of being
224    ///   resampled. Typical value `1e-6`.
225    ///
226    /// # Panics
227    /// Panics if `capacity == 0`, `obs_dim == 0`, `alpha` is outside
228    /// `[0, 1]`, or `epsilon < 0`.
229    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    /// Push a single transition into the buffer with the current
257    /// maximum priority (or `1.0` if the buffer is empty).
258    ///
259    /// When the buffer is at capacity, this overwrites the oldest
260    /// transition (FIFO eviction); the corresponding leaf in the
261    /// sum-tree is overwritten with the new priority.
262    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        // Newly inserted transitions get max_priority so they're sampled
287        // at least once before their priority is set by a TD error.
288        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    /// Number of transitions currently in the buffer.
297    pub fn len(&self) -> usize {
298        self.len
299    }
300
301    /// `true` if the buffer holds no transitions.
302    pub fn is_empty(&self) -> bool {
303        self.len == 0
304    }
305
306    /// Maximum number of transitions the buffer can hold.
307    pub fn capacity(&self) -> usize {
308        self.capacity
309    }
310
311    /// Observation dimension (length of one obs slice).
312    pub fn obs_dim(&self) -> usize {
313        self.obs_dim
314    }
315
316    /// Priority exponent `α`.
317    pub fn alpha(&self) -> f32 {
318        self.alpha
319    }
320
321    /// Priority floor `ε`.
322    pub fn epsilon(&self) -> f32 {
323        self.epsilon
324    }
325
326    /// Maximum leaf priority currently in the tree.
327    ///
328    /// Useful for diagnostics; the buffer also tracks this internally
329    /// so `push` can hand fresh transitions a max-priority slot.
330    pub fn max_priority(&self) -> f32 {
331        self.max_priority
332    }
333
334    /// `true` if the buffer holds at least `min_size` transitions.
335    pub fn is_ready(&self, min_size: usize) -> bool {
336        self.len >= min_size
337    }
338
339    /// Sample a minibatch using stratified proportional sampling.
340    ///
341    /// Divides `[0, total_priority]` into `batch_size` equal segments
342    /// and draws one uniform `p` from each, then walks the sum-tree to
343    /// find the leaf whose cumulative-priority interval contains `p`.
344    /// Stratification reduces variance vs. drawing `batch_size`
345    /// independent uniforms from `[0, total]` (Schaul §3.3).
346    ///
347    /// Importance-sampling weights are computed as
348    /// `wᵢ = (1 / (N · P(i)))^β` and then normalized by `max(wⱼ)` so the
349    /// largest weight is exactly `1.0`.
350    ///
351    /// # Arguments
352    /// * `batch_size` - Number of transitions to draw. Must be > 0.
353    /// * `beta` - IS-weight exponent. Caller anneals from a small value (e.g.
354    ///   `0.4`) to `1.0` over training.
355    /// * `rng` - source of randomness.
356    ///
357    /// # Panics
358    /// Panics if `self.is_empty()`, `batch_size == 0`, or the tree's
359    /// total priority is zero (all leaves were explicitly set to zero —
360    /// shouldn't happen in normal use because `push` always uses
361    /// `max_priority ≥ ε^α > 0`).
362    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            // `random_range(lo..hi)` would panic if `lo == hi` (zero-width
384            // segment) — in that case just take the boundary.
385            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            // Defensive: if we somehow landed on a leaf past `self.len`
393            // (only possible if the user manually zeroed all "valid"
394            // leaves and left a stray non-zero leaf in the tail), clamp.
395            // This can't happen in normal usage.
396            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            // P(i) = leaf_priority / total → w_i = (1 / (N * P(i)))^β
408            //                                   = (total / (N * leaf_priority))^β
409            let prob = leaf_priority.max(1e-12) / total;
410            raw_weights[k] = (1.0 / (n * prob)).powf(beta);
411        }
412
413        // Normalize so max(weights) == 1.0.
414        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    /// Update the priorities of a set of leaf indices with new TD errors.
434    ///
435    /// For each `(idx, td_err)` pair, the stored priority becomes
436    /// `(|td_err| + ε)^α`. The `max_priority` tracker is bumped so
437    /// future [`Self::push`] calls inherit at least this value.
438    ///
439    /// # Panics
440    /// Panics if `indices` and `td_errors` have different lengths or if
441    /// any index is out of range.
442    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            // (|δ| + ε)^α
454            let priority = (magnitude + self.epsilon).powf(self.alpha);
455            // Guard against the (impossible-in-practice) case where
456            // (magnitude + epsilon)^alpha = NaN due to a NaN td_err.
457            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    /// Borrow the underlying sum-tree (useful for tests / diagnostics).
470    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        // After update_priorities raises max, subsequent push should
510        // use the bumped value (so freshly-inserted transitions are
511        // sampled at least once even if older ones have huge priorities).
512        let mut buf = PrioritizedReplayBuffer::new(4, 2, 0.6, 1e-6);
513        push_synth(&mut buf, 2);
514        // Bump leaf 0 to a huge priority via an enormous TD error.
515        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        // Push another transition; its priority should equal bumped_max.
524        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        // Push N transitions with varying priorities, sample many times,
537        // and verify the sampling frequency is proportional to priority
538        // within a tolerance.
539        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        // Set priorities to [1, 2, 3, 4] (with α = 1, ε = 0 so the
545        // stored priority is exactly |td|).
546        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        // Use batch_size=1 so each sample is independent (otherwise the
555        // stratified sampler enforces one draw per segment which biases
556        // the per-index histogram for small N).
557        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        // Push 4 transitions, then set one priority to ~0 via
579        // update_priorities (the actual stored priority is
580        // (0 + ε)^α = ε^α, which is what we set ε = 0 for here).
581        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        // Default priorities are all max (1.0 each). Zero out leaf 2.
586        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        // Vary priorities so IS weights aren't all equal.
606        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        // After sampling, calling update_priorities with the returned
624        // indices should change exactly those leaves and leave others
625        // alone.
626        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        // Equal priorities.
631        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        // Update each sampled index with a known TD error.
637        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        // Group by index in case the sampler drew duplicates.
641        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        // Edge case: capacity == 1 means SumTree has a single node.
660        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        // Capacity 4, push 6 → first 2 must be gone, latest 4 must be
739        // there. Sample many times; we should only see actions 2..6.
740        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}