Skip to main content

thrust_rl/train/dqn/
trainer.rs

1//! Burn-backend DQN trainer (phase 3 of the Burn migration, #80).
2//!
3//! Sibling to [`crate::train::dqn::DQNTrainerBurn`] (tch path). Implements
4//! the same Double-DQN target / Smooth-L1 loss / gradient-step recipe
5//! but holds the Q-network as a Burn module that flows through the
6//! optimizer on every step.
7//!
8//! # Scope (phase 3)
9//!
10//! The Burn trainer:
11//!
12//! - Owns an online Q-network module `Q` and a target Q-network module of the
13//!   same type. The trainer is generic over `Q: AutodiffModule<B> + Clone` so
14//!   the actual network shape (CartPole MLP, Snake CNN, etc.) ships in phase 4.
15//! - Provides ε-greedy action selection through a caller-supplied `greedy_fn`
16//!   (the network's forward + argmax). The trainer owns the ε schedule and the
17//!   env-step counter — the same as the tch trainer's `select_action` API.
18//! - Performs the Smooth-L1 / Double-DQN training step inside
19//!   [`DQNTrainerBurn::train_step`]. Caller-supplied closures hand off the
20//!   forward pass — exactly the shape the tch trainer uses when `train_step` is
21//!   called against an external loss closure.
22//! - Pushes transitions into a [`crate::buffer::replay::ReplayBuffer`] and
23//!   samples minibatches the same way as the tch trainer.
24//! - Soft / Polyak target updates are folded into
25//!   [`DQNTrainerBurn::maybe_sync_target`], which the caller invokes with a
26//!   blend closure that applies the `tau` from config.
27//!
28//! # NOT in scope for phase 3
29//!
30//! - Prioritized replay is intentionally **not** ported in this phase — it
31//!   would inflate the LOC delta past the 800-line budget called out on issue
32//!   #80. The hooks are still there (`huber_per_sample` is exposed on the loss
33//!   module) and phase 4 / 5 can add the IS-weighted path.
34//! - Hard / interval-based target sync is implemented as `clone()` of the
35//!   online module into the target slot when the env-step counter hits the
36//!   interval; the soft path uses Burn's module-tree mutation through the
37//!   trainer-provided `soft_blend_fn`.
38//!
39//! The acceptance test for phase 3 is that `train_cartpole_dqn` can be
40//! ported to this trainer with no algorithmic divergence; the
41//! prioritized-replay diagnostics live with `crate::train::dqn` until
42//! phase 5 drops them.
43
44use anyhow::{Result, anyhow};
45use burn::{
46    module::{AutodiffModule, list_param_ids},
47    optim::{GradientsParams, Optimizer},
48    prelude::ToElement,
49    tensor::{Tensor, backend::AutodiffBackend},
50};
51use rand::Rng;
52
53use super::{
54    config::DQNConfig,
55    loss::{compute_loss, compute_td_target, compute_td_target_double, gather_action_q},
56};
57use crate::{
58    buffer::replay::{ReplayBuffer, sample},
59    train::optimizer::{BackendOptimizer, BurnOptimizer},
60};
61
62/// Per-step training statistics for the Burn DQN trainer.
63///
64/// Mirrors `crate::train::dqn::DQNStepStats` (tch path) field-for-field
65/// where the fields make sense for the Burn / non-prioritized path.
66/// Prioritized-replay fields (`beta`, `mean_abs_td_error`) are dropped
67/// because the Burn trainer does not yet implement prioritized replay
68/// (see module doc).
69#[derive(Debug, Clone, Copy)]
70pub struct DQNStepStatsBurn {
71    /// Mean Smooth-L1 (Huber) loss across the minibatch.
72    pub td_loss: f64,
73    /// Mean of `Q(s, a)` across the minibatch.
74    pub mean_q: f64,
75    /// ε used to draw the most recent action.
76    pub epsilon: f64,
77    /// Replay buffer fill level at the time of this update.
78    pub buffer_len: usize,
79}
80
81/// Burn-backend DQN trainer.
82///
83/// Generic over:
84/// - `B: AutodiffBackend`,
85/// - `Q: AutodiffModule<B> + Clone` — the Q-network module type (single
86///   backbone + action-dim head; ports in phase 4),
87/// - `O: Optimizer<Q, B>` — the Burn optimizer.
88pub struct DQNTrainerBurn<B, Q, O>
89where
90    B: AutodiffBackend,
91    Q: AutodiffModule<B> + Clone,
92    O: Optimizer<Q, B>,
93{
94    config: DQNConfig,
95    n_actions: i64,
96    online: Option<Q>,
97    target: Q,
98    optimizer: BurnOptimizer<B, Q, O>,
99    buffer: ReplayBuffer,
100    device: B::Device,
101    total_env_steps: usize,
102    total_train_steps: usize,
103    total_episodes: usize,
104    last_epsilon: f64,
105}
106
107impl<B, Q, O> DQNTrainerBurn<B, Q, O>
108where
109    B: AutodiffBackend,
110    Q: AutodiffModule<B> + Clone,
111    O: Optimizer<Q, B>,
112{
113    /// Build a new Burn DQN trainer.
114    ///
115    /// The caller supplies the online network already initialized; the
116    /// trainer clones it once to seed the target network so the two
117    /// start byte-equal (matching the tch trainer's
118    /// `target.copy_params_from(&online)` semantics).
119    pub fn new(
120        config: DQNConfig,
121        online: Q,
122        optimizer: BurnOptimizer<B, Q, O>,
123        obs_dim: usize,
124        n_actions: i64,
125        device: B::Device,
126    ) -> Result<Self> {
127        config.validate()?;
128        if config.prioritized_replay {
129            return Err(anyhow!(
130                "DQNTrainerBurn does not yet implement prioritized replay (phase 3 \
131                 scope on issue #80). Use the tch DQNTrainer or wait for phase 5."
132            ));
133        }
134        let target = online.clone();
135        let buffer = ReplayBuffer::new(config.buffer_capacity, obs_dim);
136        let last_epsilon = config.epsilon_start;
137        Ok(Self {
138            config,
139            n_actions,
140            online: Some(online),
141            target,
142            optimizer,
143            buffer,
144            device,
145            total_env_steps: 0,
146            total_train_steps: 0,
147            total_episodes: 0,
148            last_epsilon,
149        })
150    }
151
152    /// Number of discrete actions in this trainer's action space.
153    pub fn n_actions(&self) -> i64 {
154        self.n_actions
155    }
156
157    /// Borrow the configuration.
158    pub fn config(&self) -> &DQNConfig {
159        &self.config
160    }
161
162    /// Borrow the online network. Panics if mid-step.
163    pub fn online(&self) -> &Q {
164        self.online.as_ref().expect("online network is None mid-step")
165    }
166
167    /// Borrow the target network.
168    pub fn target(&self) -> &Q {
169        &self.target
170    }
171
172    /// Borrow the replay buffer.
173    pub fn buffer(&self) -> &ReplayBuffer {
174        &self.buffer
175    }
176
177    /// Mutably borrow the replay buffer.
178    pub fn buffer_mut(&mut self) -> &mut ReplayBuffer {
179        &mut self.buffer
180    }
181
182    /// Number of transitions currently in the buffer.
183    pub fn buffer_len(&self) -> usize {
184        self.buffer.len()
185    }
186
187    /// Caller invokes this once per environment step.
188    pub fn increment_env_step(&mut self) {
189        self.total_env_steps += 1;
190    }
191
192    /// Caller invokes this when an episode terminates / truncates.
193    pub fn increment_episodes(&mut self, n: usize) {
194        self.total_episodes += n;
195    }
196
197    /// Current env-step counter.
198    pub fn total_env_steps(&self) -> usize {
199        self.total_env_steps
200    }
201
202    /// Number of completed gradient updates.
203    pub fn total_train_steps(&self) -> usize {
204        self.total_train_steps
205    }
206
207    /// Number of completed episodes.
208    pub fn total_episodes(&self) -> usize {
209        self.total_episodes
210    }
211
212    /// ε used to draw the most recent action.
213    pub fn last_epsilon(&self) -> f64 {
214        self.last_epsilon
215    }
216
217    /// ε-greedy action selection.
218    ///
219    /// Computes the current ε using the config's
220    /// [`DQNConfig::epsilon_at`] schedule, then either picks a uniform
221    /// random action with probability ε or invokes `greedy_fn` for the
222    /// argmax-Q action.
223    pub fn select_action<R: Rng, F>(&mut self, obs: &[f32], rng: &mut R, greedy_fn: F) -> i64
224    where
225        F: FnOnce(&Q, &[f32]) -> i64,
226    {
227        let eps = self.config.epsilon_at(self.total_env_steps);
228        self.last_epsilon = eps;
229        if rng.random::<f64>() < eps {
230            rng.random_range(0..self.n_actions)
231        } else {
232            greedy_fn(self.online(), obs)
233        }
234    }
235
236    /// Sync the target network from the online network.
237    ///
238    /// Two modes:
239    /// 1. **Hard sync** (default): clones online → target every
240    ///    `target_update_interval` env steps.
241    /// 2. **Soft / Polyak** (`config.soft_update_tau = Some(τ)`): the
242    ///    caller-supplied `blend_fn` is invoked every step. The caller is
243    ///    responsible for implementing the per-parameter blend `θ_target ← τ ·
244    ///    θ_online + (1 − τ) · θ_target`. Burn 0.21 does not expose a uniform
245    ///    `map_params` API, so the blend is parameterized over the concrete
246    ///    module type by the caller.
247    pub fn maybe_sync_target<F>(&mut self, blend_fn: F) -> bool
248    where
249        F: FnOnce(&Q, Q, f64) -> Q,
250    {
251        match self.config.soft_update_tau {
252            Some(tau) => {
253                let online = self.online().clone();
254                let target = std::mem::replace(&mut self.target, online.clone());
255                self.target = blend_fn(&online, target, tau);
256                true
257            }
258            None => {
259                if self.total_env_steps > 0
260                    && self.total_env_steps.is_multiple_of(self.config.target_update_interval)
261                {
262                    self.target = self.online().clone();
263                    true
264                } else {
265                    false
266                }
267            }
268        }
269    }
270
271    /// Sample a minibatch and run one gradient step against the
272    /// Double-DQN TD target.
273    ///
274    /// Caller supplies two closures:
275    /// - `forward_fn(&Q, obs)` — forward pass returning the `[batch,
276    ///   n_actions]` Q-values, with grad bearing iff the module is the online
277    ///   network.
278    /// - `forward_target_fn(&Q, obs)` — forward pass returning the `[batch,
279    ///   n_actions]` target-net Q-values. The trainer takes care of detaching
280    ///   these for the TD target — caller doesn't need to manage `no_grad`.
281    ///
282    /// Returns `Ok(None)` if the buffer doesn't yet hold
283    /// `min_buffer_size` transitions.
284    pub fn train_step<R: Rng, FOnline, FTarget>(
285        &mut self,
286        rng: &mut R,
287        forward_fn: FOnline,
288        forward_target_fn: FTarget,
289    ) -> Result<Option<DQNStepStatsBurn>>
290    where
291        FOnline: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
292        FTarget: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
293    {
294        if !self.buffer.is_ready(self.config.min_buffer_size) {
295            return Ok(None);
296        }
297
298        let batch = sample(&self.buffer, self.config.batch_size, rng);
299        let buffer_len = self.buffer.len();
300
301        // Lift the replay batch into Burn tensors via the buffer's
302        // built-in `to_burn_tensors` helper (phase 2a, #79).
303        let t = batch.to_burn_tensors::<B>(&self.device);
304
305        let online = self
306            .online
307            .take()
308            .ok_or_else(|| anyhow!("online network is None; concurrent train_step calls?"))?;
309
310        let q_online_all = forward_fn(&online, t.observations);
311        let q_taken = gather_action_q(q_online_all.clone(), t.actions);
312
313        // Target / Double-DQN target — both forward passes use the
314        // online + target networks but the resulting target tensor is
315        // detached inside `compute_td_target_double`, so no gradient
316        // flows through them.
317        let next_q_online_all = forward_fn(&online, t.next_observations.clone());
318        let next_q_target_all = forward_target_fn(&self.target, t.next_observations);
319        let td_target = compute_td_target_double(
320            t.rewards,
321            t.dones,
322            next_q_online_all,
323            next_q_target_all,
324            self.config.gamma,
325        );
326
327        let td_loss = compute_loss(q_taken.clone(), td_target);
328        let td_loss_val: f64 = td_loss.clone().into_scalar().to_f64();
329        let mean_q_val: f64 = q_taken.mean().into_scalar().to_f64();
330
331        if !td_loss_val.is_finite() {
332            return Err(anyhow!("Non-finite TD loss: {}", td_loss_val));
333        }
334
335        // Burn optimizer step.
336        let grads = td_loss.backward();
337        let grads = GradientsParams::from_grads(grads, &online);
338        let lr = self.optimizer.learning_rate();
339        let online = self.optimizer.inner_mut().step(lr, online, grads);
340        self.online = Some(online);
341
342        self.total_train_steps += 1;
343
344        Ok(Some(DQNStepStatsBurn {
345            td_loss: td_loss_val,
346            mean_q: mean_q_val,
347            epsilon: self.last_epsilon,
348            buffer_len,
349        }))
350    }
351
352    /// Loss-scaled Double-DQN train step for reduced-precision (f16)
353    /// backends.
354    ///
355    /// This is an **additive** sibling of [`DQNTrainerBurn::train_step`]
356    /// used only by the opt-in `training-fp16` example
357    /// (`examples/games/atari/train_pong_dqn_fp16.rs`). The full-precision
358    /// `train_step` above is left bit-identical to its pre-fp16 behavior —
359    /// callers that do not opt into loss scaling keep the exact same code
360    /// path.
361    ///
362    /// # Why loss scaling exists
363    ///
364    /// f16 has only a ~5-bit exponent (min normal ≈ 6.1e-5). Gradients that
365    /// backpropagate through the Nature-DQN's conv stack routinely fall below
366    /// that floor and underflow to zero, stalling learning. Multiplying the
367    /// loss by `loss_scale` before `.backward()` shifts every gradient up by
368    /// the same factor into f16's representable range; dividing the gradients
369    /// back down by `loss_scale` before the optimizer step recovers the true
370    /// update. bf16 (full f32 exponent range) would not need this, but bf16
371    /// matmul is unavailable on the wgpu/Metal runtime in Burn 0.21 (see #305)
372    /// — CUDA f16 is the verified path.
373    ///
374    /// # Overflow handling
375    ///
376    /// The caller owns the *dynamic* scale schedule (halve on overflow, grow
377    /// after a clean streak). This method reports back whether the step was
378    /// numerically clean so the caller can adjust:
379    ///
380    /// - Returns `Ok(None)` if the buffer has not reached `min_buffer_size`.
381    /// - Returns `Ok(Some((stats, applied)))` otherwise. `applied == false`
382    ///   means the step **overflowed** and was skipped (the online network is
383    ///   left unchanged); the caller should **halve** the scale and retry.
384    ///
385    /// # How overflow is detected (the important subtlety)
386    ///
387    /// The overflow that matters happens *inside the scaled backward pass*, not
388    /// in the raw loss. With an f16 backend the loss tensor is itself f16
389    /// (max ≈ 65504), so `loss × loss_scale` **overflows to ±inf on the
390    /// device** once the scale is large — and that inf then poisons the
391    /// gradients. A finiteness check on the *unscaled* loss would miss this
392    /// entirely (the unscaled loss is a small finite number). We therefore
393    /// compute the **scaled** loss on-device and read *its* host scalar: if
394    /// `loss × scale` is non-finite, the backward pass would produce
395    /// non-finite gradients, so we skip. We also guard the unscaled loss
396    /// for a genuine NaN in the forward pass. This is the cheapest reliable
397    /// overflow proxy — Burn 0.21 exposes no per-gradient finiteness API.
398    ///
399    /// `stats.td_loss` is always the **unscaled** loss (comparable to the f32
400    /// path's `td_loss`), regardless of `loss_scale`.
401    pub fn train_step_scaled<R: Rng, FOnline, FTarget>(
402        &mut self,
403        rng: &mut R,
404        loss_scale: f64,
405        forward_fn: FOnline,
406        forward_target_fn: FTarget,
407    ) -> Result<Option<(DQNStepStatsBurn, bool)>>
408    where
409        FOnline: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
410        FTarget: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
411    {
412        if !self.buffer.is_ready(self.config.min_buffer_size) {
413            return Ok(None);
414        }
415
416        let batch = sample(&self.buffer, self.config.batch_size, rng);
417        let buffer_len = self.buffer.len();
418
419        let t = batch.to_burn_tensors::<B>(&self.device);
420
421        let online = self
422            .online
423            .take()
424            .ok_or_else(|| anyhow!("online network is None; concurrent train_step calls?"))?;
425
426        let q_online_all = forward_fn(&online, t.observations);
427        let q_taken = gather_action_q(q_online_all.clone(), t.actions);
428
429        let next_q_online_all = forward_fn(&online, t.next_observations.clone());
430        let next_q_target_all = forward_target_fn(&self.target, t.next_observations);
431        let td_target = compute_td_target_double(
432            t.rewards,
433            t.dones,
434            next_q_online_all,
435            next_q_target_all,
436            self.config.gamma,
437        );
438
439        let td_loss = compute_loss(q_taken.clone(), td_target);
440
441        // Scale the loss *on-device* first, then read back both the unscaled
442        // and scaled host scalars. `scaled_loss` overflows f16 to ±inf exactly
443        // when `loss × scale` exceeds the f16 max — this is the overflow we
444        // must catch before the backward pass poisons the gradients.
445        let scaled_loss = td_loss.clone().mul_scalar(loss_scale as f32);
446        let td_loss_val: f64 = td_loss.into_scalar().to_f64();
447        let scaled_loss_val: f64 = scaled_loss.clone().into_scalar().to_f64();
448        let mean_q_val: f64 = q_taken.mean().into_scalar().to_f64();
449
450        let stats = DQNStepStatsBurn {
451            td_loss: td_loss_val,
452            mean_q: mean_q_val,
453            epsilon: self.last_epsilon,
454            buffer_len,
455        };
456
457        // Overflow / non-finite guard: skip the optimizer step and leave the
458        // network untouched so the caller can shrink the scale and retry. We
459        // require BOTH the unscaled loss (catches forward-pass NaN) and the
460        // scaled loss (catches the `loss × scale` f16 overflow) to be finite.
461        if !td_loss_val.is_finite() || !scaled_loss_val.is_finite() {
462            // Restore the online network we `take()`-en above (unmodified).
463            self.online = Some(online);
464            return Ok(Some((stats, false)));
465        }
466
467        // Backward on the scaled loss so f16 gradients stay above the underflow
468        // floor, then unscale the gradients before the optimizer consumes them.
469        let grads = scaled_loss.backward();
470        let grads = GradientsParams::from_grads(grads, &online);
471        let grads = unscale_grads::<B, Q>(grads, &online, loss_scale);
472
473        let lr = self.optimizer.learning_rate();
474        let online = self.optimizer.inner_mut().step(lr, online, grads);
475        self.online = Some(online);
476
477        self.total_train_steps += 1;
478
479        Ok(Some((stats, true)))
480    }
481
482    /// Vanilla-DQN train step (uses [`compute_td_target`] instead of
483    /// the Double-DQN target). Exposed for completeness; the default
484    /// `train_step` uses Double-DQN to match the tch trainer's default.
485    pub fn train_step_vanilla<R: Rng, FOnline, FTarget>(
486        &mut self,
487        rng: &mut R,
488        forward_fn: FOnline,
489        forward_target_fn: FTarget,
490    ) -> Result<Option<DQNStepStatsBurn>>
491    where
492        FOnline: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
493        FTarget: Fn(&Q, Tensor<B, 2>) -> Tensor<B, 2>,
494    {
495        if !self.buffer.is_ready(self.config.min_buffer_size) {
496            return Ok(None);
497        }
498
499        let batch = sample(&self.buffer, self.config.batch_size, rng);
500        let buffer_len = self.buffer.len();
501
502        let t = batch.to_burn_tensors::<B>(&self.device);
503
504        let online = self
505            .online
506            .take()
507            .ok_or_else(|| anyhow!("online network is None; concurrent train_step calls?"))?;
508
509        let q_online_all = forward_fn(&online, t.observations);
510        let q_taken = gather_action_q(q_online_all.clone(), t.actions);
511        let next_q_target_all = forward_target_fn(&self.target, t.next_observations);
512        let td_target = compute_td_target(t.rewards, t.dones, next_q_target_all, self.config.gamma);
513
514        let td_loss = compute_loss(q_taken.clone(), td_target);
515        let td_loss_val: f64 = td_loss.clone().into_scalar().to_f64();
516        let mean_q_val: f64 = q_taken.mean().into_scalar().to_f64();
517
518        if !td_loss_val.is_finite() {
519            return Err(anyhow!("Non-finite TD loss: {}", td_loss_val));
520        }
521
522        let grads = td_loss.backward();
523        let grads = GradientsParams::from_grads(grads, &online);
524        let lr = self.optimizer.learning_rate();
525        let online = self.optimizer.inner_mut().step(lr, online, grads);
526        self.online = Some(online);
527
528        self.total_train_steps += 1;
529
530        Ok(Some(DQNStepStatsBurn {
531            td_loss: td_loss_val,
532            mean_q: mean_q_val,
533            epsilon: self.last_epsilon,
534            buffer_len,
535        }))
536    }
537}
538
539/// Divide every gradient tensor in `grads` by `loss_scale`, recovering the
540/// true (unscaled) gradient after a loss-scaled backward pass.
541///
542/// Burn 0.21's [`GradientsParams`] is keyed by `ParamId` and its tensors are
543/// stored dimension-erased, so the unscale walks the module's parameter ids
544/// (via [`list_param_ids`]) and dispatches on the three tensor ranks the
545/// Nature-DQN uses: rank-1 (biases), rank-2 (`Linear` weights), and rank-4
546/// (`Conv2d` weights). Ids whose gradient is absent (e.g. a frozen param) or of
547/// an unexpected rank are passed through untouched. Uses the *inner*
548/// (non-autodiff) backend `B::InnerBackend`, since gradients are inner tensors.
549fn unscale_grads<B, Q>(mut grads: GradientsParams, module: &Q, loss_scale: f64) -> GradientsParams
550where
551    B: AutodiffBackend,
552    Q: AutodiffModule<B> + Clone,
553{
554    type Inner<B> = <B as AutodiffBackend>::InnerBackend;
555    let inv = (1.0 / loss_scale) as f32;
556    for id in list_param_ids(module) {
557        if let Some(g) = grads.remove::<Inner<B>, 1>(id) {
558            grads.register::<Inner<B>, 1>(id, g.mul_scalar(inv));
559        } else if let Some(g) = grads.remove::<Inner<B>, 2>(id) {
560            grads.register::<Inner<B>, 2>(id, g.mul_scalar(inv));
561        } else if let Some(g) = grads.remove::<Inner<B>, 4>(id) {
562            grads.register::<Inner<B>, 4>(id, g.mul_scalar(inv));
563        }
564    }
565    grads
566}
567
568#[cfg(test)]
569mod tests {
570    use burn::{
571        backend::{Autodiff, NdArray},
572        optim::AdamConfig,
573    };
574    use rand::SeedableRng;
575
576    use super::*;
577    use crate::{policy::mlp::MlpBurnPolicy, train::optimizer::BurnOptimizer};
578
579    type B = Autodiff<NdArray<f32>>;
580
581    fn small_config() -> DQNConfig {
582        DQNConfig::new()
583            .buffer_capacity(128)
584            .min_buffer_size(8)
585            .batch_size(8)
586            .target_update_interval(4)
587            .epsilon_decay_steps(100)
588    }
589
590    /// Smoke test: a Burn DQN trainer constructs without error using
591    /// `MlpBurnPolicy` as a Q-network stand-in (phase 4 ports the
592    /// proper `QNetworkBurn`).
593    #[test]
594    fn dqn_trainer_burn_constructs() {
595        let device = Default::default();
596        let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
597        let inner_opt = AdamConfig::new().init();
598        let burn_opt = BurnOptimizer::new(inner_opt, small_config().learning_rate);
599        let trainer = DQNTrainerBurn::new(small_config(), online, burn_opt, 4, 2, device).unwrap();
600        assert_eq!(trainer.total_env_steps(), 0);
601        assert_eq!(trainer.buffer_len(), 0);
602    }
603
604    /// Prioritized replay is intentionally not yet supported on the
605    /// Burn path.
606    #[test]
607    fn dqn_trainer_burn_rejects_prioritized_config() {
608        let device = Default::default();
609        let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
610        let inner_opt = AdamConfig::new().init();
611        let burn_opt = BurnOptimizer::new(inner_opt, 1e-3);
612        let cfg = small_config().prioritized_replay(true);
613        assert!(DQNTrainerBurn::new(cfg, online, burn_opt, 4, 2, device).is_err());
614    }
615
616    /// End-to-end: pushing transitions and calling `train_step` runs
617    /// the Smooth-L1 / Double-DQN gradient step without panicking.
618    /// Uses `MlpBurnPolicy` as a Q-network stand-in — its `forward`
619    /// returns `(logits, value)`; we use the logits as the Q-values.
620    #[test]
621    fn dqn_trainer_burn_train_step_runs() {
622        let device = Default::default();
623        let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
624        let inner_opt = AdamConfig::new().init();
625        let burn_opt = BurnOptimizer::new(inner_opt, 1e-3);
626        let mut trainer =
627            DQNTrainerBurn::new(small_config(), online, burn_opt, 4, 2, device).unwrap();
628
629        // Push enough transitions to clear min_buffer_size.
630        for i in 0..32 {
631            let phase = (i as f32) * 0.1;
632            let obs = [phase.sin(), phase.cos(), phase * 0.5, phase * -0.3];
633            let next_obs = [(phase + 0.1).sin(), (phase + 0.1).cos(), phase * 0.5, phase * -0.3];
634            let action = (i % 2) as i64;
635            let reward = if action == 0 { 1.0 } else { -1.0 };
636            let done = i % 8 == 7;
637            trainer.buffer_mut().push(&obs, action, reward, &next_obs, done);
638        }
639
640        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
641        let forward_fn = |q: &MlpBurnPolicy<B>, o: Tensor<B, 2>| -> Tensor<B, 2> {
642            let (logits, _) = q.forward(o);
643            logits
644        };
645        let stats = trainer.train_step(&mut rng, forward_fn, forward_fn).unwrap();
646        assert!(stats.is_some());
647        let s = stats.unwrap();
648        assert!(s.td_loss.is_finite());
649    }
650
651    /// The additive loss-scaled step (used by the `training-fp16` example)
652    /// runs the same Double-DQN update with a scale/unscale wrapper around the
653    /// backward pass. On the f32 NdArray backend it must produce a finite,
654    /// applied step and increment the train-step counter.
655    #[test]
656    fn dqn_trainer_burn_train_step_scaled_runs() {
657        let device = Default::default();
658        let online = MlpBurnPolicy::<B>::new(4, 2, 16, &device);
659        let inner_opt = AdamConfig::new().init();
660        let burn_opt = BurnOptimizer::new(inner_opt, 1e-3);
661        let mut trainer =
662            DQNTrainerBurn::new(small_config(), online, burn_opt, 4, 2, device).unwrap();
663
664        for i in 0..32 {
665            let phase = (i as f32) * 0.1;
666            let obs = [phase.sin(), phase.cos(), phase * 0.5, phase * -0.3];
667            let next_obs = [(phase + 0.1).sin(), (phase + 0.1).cos(), phase * 0.5, phase * -0.3];
668            let action = (i % 2) as i64;
669            let reward = if action == 0 { 1.0 } else { -1.0 };
670            let done = i % 8 == 7;
671            trainer.buffer_mut().push(&obs, action, reward, &next_obs, done);
672        }
673
674        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
675        let forward_fn = |q: &MlpBurnPolicy<B>, o: Tensor<B, 2>| -> Tensor<B, 2> {
676            let (logits, _) = q.forward(o);
677            logits
678        };
679        let out = trainer.train_step_scaled(&mut rng, 32_768.0, forward_fn, forward_fn).unwrap();
680        assert!(out.is_some());
681        let (s, applied) = out.unwrap();
682        assert!(applied, "scaled step should apply on finite loss");
683        assert!(s.td_loss.is_finite());
684        assert_eq!(trainer.total_train_steps(), 1);
685    }
686}