Skip to main content

thrust_rl/train/sac/
trainer.rs

1//! Soft Actor-Critic (SAC) trainer for continuous control.
2//!
3//! [`SacTrainer`] is the integration piece of the SAC decomposition
4//! (#136, PR E): it wires the four building blocks —
5//! [`SacActor`](crate::policy::sac_actor::SacActor) (#140),
6//! [`ContinuousQNetwork`](crate::policy::continuous_q::ContinuousQNetwork)
7//! twin critics + targets (#141),
8//! [`ContinuousReplayBuffer`](crate::buffer::replay::ContinuousReplayBuffer)
9//! (#138), and the
10//! [`PendulumSwingUp`](crate::env::games::pendulum::PendulumSwingUp) env (#139)
11//! — into the Soft Actor-Critic algorithm of Haarnoja et al. 2018 v2 (arXiv:
12//! 1812.05905).
13//!
14//! # Owned state
15//!
16//! - A stochastic [`SacActor`](crate::policy::sac_actor::SacActor).
17//! - Two online critics `q1`, `q2` and their targets `q1_target`, `q2_target`.
18//! - A [`LogAlpha`] module holding `log_alpha` (the log entropy temperature),
19//!   tuned only when [`SacConfig::auto_alpha`] is set.
20//! - **Three independent Adam optimizers** (actor, critics, alpha). Burn's
21//!   optimizer is move-through (it consumes its module by value per step), so
22//!   the five networks cannot live in one fused module; they are owned as
23//!   separate fields and stepped independently. The two online critics share a
24//!   single optimizer that steps them in sequence.
25//! - A [`ContinuousReplayBuffer`](crate::buffer::replay::ContinuousReplayBuffer)
26//!   and a seeded [`StdRng`](rand::rngs::StdRng).
27//!
28//! # Update equations
29//!
30//! For a minibatch `(s, a, r, s', d)`:
31//!
32//! - **Critic target** (`a' ~ actor.sample(s')` from the *current* actor): `y =
33//!   r + gamma * (1 - d) * (min(Q1_t, Q2_t)(s', a') - alpha *
34//!   log_prob(a'|s'))`. Both online critics regress to `y` with an MSE loss;
35//!   the target is detached.
36//! - **Actor** (reparameterized `a_pi ~ actor.sample(s)`): `loss = mean(alpha *
37//!   log_prob(a_pi|s) - min(Q1, Q2)(s, a_pi))`. The critics are frozen
38//!   (detached) for this loss so only the actor moves.
39//! - **Alpha** (when auto-tuning): `loss = -mean(log_alpha * (log_prob +
40//!   target_entropy))` with the `(log_prob + target_entropy)` factor detached;
41//!   `alpha = exp(log_alpha)`.
42//! - **Targets**: Polyak soft update every gradient step, `theta_target <- tau
43//!   * theta_online + (1 - tau) * theta_target`.
44
45use anyhow::{Result, anyhow};
46use burn::{
47    grad_clipping::GradientClippingConfig,
48    module::{Module, Param},
49    optim::{Adam, AdamConfig, GradientsParams, Optimizer, adaptor::OptimizerAdaptor},
50    prelude::ToElement,
51    tensor::{
52        Tensor,
53        backend::{AutodiffBackend, Backend},
54    },
55};
56use rand::{Rng, SeedableRng, rngs::StdRng};
57
58use super::config::SacConfig;
59use crate::{
60    buffer::replay::{ContinuousReplayBuffer, sample_continuous},
61    policy::{
62        continuous_q::{ContinuousQNetwork, ContinuousQNetworkConfig},
63        mlp::BurnActivation,
64        sac_actor::{SacActor, SacActorConfig},
65    },
66};
67
68/// Tiny single-parameter [`Module`] holding the log of the entropy
69/// temperature `alpha`.
70///
71/// SAC's automatic temperature tuning optimizes `log_alpha` (so the
72/// recovered `alpha = exp(log_alpha)` stays strictly positive). It is a
73/// standalone module so a dedicated Adam optimizer can step it through
74/// Burn's move-through interface, exactly like the actor and critics.
75#[derive(Module, Debug)]
76pub struct LogAlpha<B: Backend> {
77    value: Param<Tensor<B, 1>>,
78}
79
80impl<B: Backend> LogAlpha<B> {
81    /// Build a `log_alpha` module initialized to `ln(init_alpha)`.
82    pub fn new(init_alpha: f32, device: &B::Device) -> Self {
83        let data = burn::tensor::TensorData::new(vec![init_alpha.ln()], [1]);
84        let tensor = Tensor::<B, 1>::from_data(data, device);
85        Self { value: Param::from_tensor(tensor) }
86    }
87
88    /// The current `log_alpha` tensor (shape `[1]`), grad-bearing.
89    pub fn value(&self) -> Tensor<B, 1> {
90        self.value.val()
91    }
92
93    /// The current `alpha = exp(log_alpha)` as a host scalar.
94    pub fn alpha_scalar(&self) -> f64 {
95        self.value.val().exp().into_scalar().to_f64()
96    }
97}
98
99/// Per-step training statistics for the SAC trainer.
100///
101/// Analogous to [`crate::train::dqn::DQNStepStatsBurn`]. Returned by
102/// [`SacTrainer::train_step`] once a gradient update has actually run.
103#[derive(Debug, Clone, Copy)]
104pub struct SacStepStats {
105    /// Mean MSE critic loss across the two online critics for this batch.
106    pub critic_loss: f64,
107    /// Actor (policy) loss for this batch.
108    pub actor_loss: f64,
109    /// Entropy-temperature loss (0.0 when `auto_alpha` is disabled).
110    pub alpha_loss: f64,
111    /// Current entropy temperature `alpha`.
112    pub alpha: f64,
113    /// Mean of `min(Q1, Q2)(s, a)` over the batch on the stored actions.
114    pub mean_q: f64,
115    /// Replay buffer fill level at the time of this update.
116    pub buffer_len: usize,
117}
118
119/// Concrete Adam optimizer type for a SAC sub-network of module type `M`.
120type SacAdam<B, M> = OptimizerAdaptor<Adam, M, B>;
121
122/// Burn-backend Soft Actor-Critic trainer.
123///
124/// Generic only over the autodiff backend `B`; the network shapes are
125/// fixed (a [`SacActor`](crate::policy::sac_actor::SacActor) plus four
126/// [`ContinuousQNetwork`](crate::policy::continuous_q::ContinuousQNetwork)
127/// critics) so the trainer can own the three concrete Adam optimizers
128/// directly.
129pub struct SacTrainer<B: AutodiffBackend> {
130    config: SacConfig,
131    obs_dim: usize,
132    action_dim: usize,
133    target_entropy: f32,
134
135    actor: Option<SacActor<B>>,
136    q1: Option<ContinuousQNetwork<B>>,
137    q2: Option<ContinuousQNetwork<B>>,
138    q1_target: ContinuousQNetwork<B>,
139    q2_target: ContinuousQNetwork<B>,
140    log_alpha: Option<LogAlpha<B>>,
141
142    actor_opt: SacAdam<B, SacActor<B>>,
143    critic_opt: SacAdam<B, ContinuousQNetwork<B>>,
144    alpha_opt: SacAdam<B, LogAlpha<B>>,
145
146    buffer: ContinuousReplayBuffer,
147    rng: StdRng,
148    device: B::Device,
149    total_env_steps: usize,
150    total_train_steps: usize,
151    total_episodes: usize,
152}
153
154impl<B: AutodiffBackend> SacTrainer<B> {
155    /// Build a new SAC trainer for an `obs_dim`-dimensional observation
156    /// and `action_dim`-dimensional continuous action space.
157    ///
158    /// All five networks are seeded off [`SacConfig::seed`] (the actor,
159    /// each critic, and the targets get distinct derived seeds so the
160    /// twin critics are decorrelated), and the targets are hard-copied
161    /// from their online counterparts so they start byte-equal.
162    pub fn new(
163        config: SacConfig,
164        obs_dim: usize,
165        action_dim: usize,
166        device: B::Device,
167    ) -> Result<Self> {
168        config.validate()?;
169        if obs_dim == 0 {
170            return Err(anyhow!("obs_dim must be positive"));
171        }
172        if action_dim == 0 {
173            return Err(anyhow!("action_dim must be positive"));
174        }
175
176        let target_entropy = config.resolved_target_entropy(action_dim);
177
178        let actor_cfg = SacActorConfig {
179            num_layers: config.num_hidden_layers,
180            hidden_dim: config.hidden_dim,
181            use_orthogonal_init: true,
182            activation: BurnActivation::ReLU,
183            seed: Some(config.seed),
184        };
185        let actor = SacActor::<B>::with_config(obs_dim, action_dim, actor_cfg, &device);
186
187        // Distinct seeds keep the twin critics decorrelated.
188        let q1_cfg = ContinuousQNetworkConfig {
189            num_layers: config.num_hidden_layers,
190            hidden_dim: config.hidden_dim,
191            use_orthogonal_init: true,
192            activation: BurnActivation::ReLU,
193            seed: Some(config.seed.wrapping_add(1)),
194        };
195        let q2_cfg = ContinuousQNetworkConfig { seed: Some(config.seed.wrapping_add(2)), ..q1_cfg };
196        let q1 = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q1_cfg, &device);
197        let q2 = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q2_cfg, &device);
198
199        // Targets start as exact copies of the online critics.
200        let q1_target = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q1_cfg, &device)
201            .copy_params_from(&q1);
202        let q2_target = ContinuousQNetwork::<B>::with_config(obs_dim, action_dim, q2_cfg, &device)
203            .copy_params_from(&q2);
204
205        let log_alpha = LogAlpha::<B>::new(config.init_alpha, &device);
206
207        let actor_opt = build_adam::<B, SacActor<B>>(config.max_grad_norm);
208        let critic_opt = build_adam::<B, ContinuousQNetwork<B>>(config.max_grad_norm);
209        let alpha_opt = build_adam::<B, LogAlpha<B>>(config.max_grad_norm);
210
211        let buffer = ContinuousReplayBuffer::new(config.buffer_capacity, obs_dim, action_dim);
212        let rng = StdRng::seed_from_u64(config.seed);
213
214        Ok(Self {
215            config,
216            obs_dim,
217            action_dim,
218            target_entropy,
219            actor: Some(actor),
220            q1: Some(q1),
221            q2: Some(q2),
222            q1_target,
223            q2_target,
224            log_alpha: Some(log_alpha),
225            actor_opt,
226            critic_opt,
227            alpha_opt,
228            buffer,
229            rng,
230            device,
231            total_env_steps: 0,
232            total_train_steps: 0,
233            total_episodes: 0,
234        })
235    }
236
237    /// Borrow the configuration.
238    pub fn config(&self) -> &SacConfig {
239        &self.config
240    }
241
242    /// Observation dimension.
243    pub fn obs_dim(&self) -> usize {
244        self.obs_dim
245    }
246
247    /// Action dimension.
248    pub fn action_dim(&self) -> usize {
249        self.action_dim
250    }
251
252    /// Effective target entropy (after the `-action_dim` heuristic).
253    pub fn target_entropy(&self) -> f32 {
254        self.target_entropy
255    }
256
257    /// Borrow the actor. Panics if called mid-step.
258    pub fn actor(&self) -> &SacActor<B> {
259        self.actor.as_ref().expect("actor is None mid-step")
260    }
261
262    /// Borrow the first online critic. Panics if called mid-step.
263    pub fn q1(&self) -> &ContinuousQNetwork<B> {
264        self.q1.as_ref().expect("q1 is None mid-step")
265    }
266
267    /// Borrow the second online critic. Panics if called mid-step.
268    pub fn q2(&self) -> &ContinuousQNetwork<B> {
269        self.q2.as_ref().expect("q2 is None mid-step")
270    }
271
272    /// Borrow the replay buffer.
273    pub fn buffer(&self) -> &ContinuousReplayBuffer {
274        &self.buffer
275    }
276
277    /// Mutably borrow the replay buffer.
278    pub fn buffer_mut(&mut self) -> &mut ContinuousReplayBuffer {
279        &mut self.buffer
280    }
281
282    /// Number of transitions currently in the buffer.
283    pub fn buffer_len(&self) -> usize {
284        self.buffer.len()
285    }
286
287    /// Current entropy temperature `alpha = exp(log_alpha)`.
288    pub fn alpha(&self) -> f64 {
289        self.log_alpha.as_ref().expect("log_alpha is None mid-step").alpha_scalar()
290    }
291
292    /// Current env-step counter.
293    pub fn total_env_steps(&self) -> usize {
294        self.total_env_steps
295    }
296
297    /// Number of completed gradient updates.
298    pub fn total_train_steps(&self) -> usize {
299        self.total_train_steps
300    }
301
302    /// Number of completed episodes.
303    pub fn total_episodes(&self) -> usize {
304        self.total_episodes
305    }
306
307    /// Caller invokes this once per environment step.
308    pub fn increment_env_step(&mut self) {
309        self.total_env_steps += 1;
310    }
311
312    /// Caller invokes this when an episode terminates / truncates.
313    pub fn increment_episodes(&mut self, n: usize) {
314        self.total_episodes += n;
315    }
316
317    /// `true` while the trainer is still in the random-action warmup
318    /// window (env steps `< learning_starts`).
319    pub fn in_warmup(&self) -> bool {
320        self.total_env_steps < self.config.learning_starts
321    }
322
323    /// Select an action for `obs` (shape `[obs_dim]`).
324    ///
325    /// During the warmup window ([`Self::in_warmup`]) the action is drawn
326    /// uniformly in `(-1, 1)` per dimension; afterwards it is a stochastic
327    /// sample from the actor. The returned action is in the tanh-squashed
328    /// `(-1, 1)` box — the env is responsible for rescaling to its own
329    /// action range.
330    pub fn select_action(&mut self, obs: &[f32]) -> Vec<f32> {
331        if self.in_warmup() {
332            (0..self.action_dim).map(|_| self.rng.random_range(-1.0..1.0)).collect()
333        } else {
334            let obs_t = Tensor::<B, 2>::from_data(
335                burn::tensor::TensorData::new(obs.to_vec(), [1, self.obs_dim]),
336                &self.device,
337            );
338            let actor = self.actor.as_ref().expect("actor is None mid-step");
339            let (action, _log_prob) = actor.sample(obs_t, &mut self.rng);
340            action.into_data().to_vec().expect("action tensor to host vec")
341        }
342    }
343
344    /// Deterministic evaluation action `tanh(mu)` for `obs` (shape
345    /// `[obs_dim]`), bypassing exploration noise.
346    pub fn eval_action(&self, obs: &[f32]) -> Vec<f32> {
347        let obs_t = Tensor::<B, 2>::from_data(
348            burn::tensor::TensorData::new(obs.to_vec(), [1, self.obs_dim]),
349            &self.device,
350        );
351        let actor = self.actor.as_ref().expect("actor is None mid-step");
352        actor
353            .mean_action(obs_t)
354            .into_data()
355            .to_vec()
356            .expect("action tensor to host vec")
357    }
358
359    /// Run `gradient_steps_per_env_step` SAC gradient updates if the
360    /// buffer is ready.
361    ///
362    /// Returns `Ok(None)` until the buffer holds `min_buffer_size`
363    /// transitions; afterwards returns the stats from the **last** of the
364    /// performed updates.
365    pub fn train(&mut self) -> Result<Option<SacStepStats>> {
366        if !self.buffer.is_ready(self.config.min_buffer_size) {
367            return Ok(None);
368        }
369        let mut last = None;
370        for _ in 0..self.config.gradient_steps_per_env_step {
371            last = Some(self.train_step()?);
372        }
373        Ok(last)
374    }
375
376    /// Run exactly one SAC gradient update (critics, actor, optional
377    /// alpha, then a Polyak soft update of both targets).
378    ///
379    /// Returns an error if any loss is non-finite. Assumes the buffer
380    /// holds at least one transition; callers typically gate this on
381    /// [`ContinuousReplayBuffer::is_ready`] via [`Self::train`].
382    pub fn train_step(&mut self) -> Result<SacStepStats> {
383        let batch = sample_continuous(&self.buffer, self.config.batch_size, &mut self.rng);
384        let buffer_len = self.buffer.len();
385        let t = batch.to_burn_tensors::<B>(&self.device);
386
387        let gamma = self.config.gamma as f32;
388
389        let mut actor = self.actor.take().ok_or_else(|| anyhow!("actor None; reentrant step?"))?;
390        let mut q1 = self.q1.take().ok_or_else(|| anyhow!("q1 None; reentrant step?"))?;
391        let mut q2 = self.q2.take().ok_or_else(|| anyhow!("q2 None; reentrant step?"))?;
392        let mut log_alpha = self
393            .log_alpha
394            .take()
395            .ok_or_else(|| anyhow!("log_alpha None; reentrant step?"))?;
396
397        let alpha = log_alpha.value().exp().detach().into_scalar().to_f32();
398
399        // ----- Critic update -----
400        // Build the TD target with a' sampled from the *current* actor at
401        // s'. The whole target is detached so no gradient flows into the
402        // actor or target critics here.
403        let (next_action, next_log_prob) = actor.sample(t.next_observations.clone(), &mut self.rng);
404        let next_action = next_action.detach();
405        let next_log_prob = next_log_prob.detach();
406
407        let q1_t = self.q1_target.forward(t.next_observations.clone(), next_action.clone());
408        let q2_t = self.q2_target.forward(t.next_observations.clone(), next_action);
409        let min_q_next = min_pair(q1_t, q2_t);
410        let soft_value = min_q_next - next_log_prob.mul_scalar(alpha);
411
412        // y = r + gamma * (1 - done) * soft_value
413        let not_done = -t.dones.clone() + 1.0;
414        let td_target = (t.rewards.clone() + soft_value.mul_scalar(gamma) * not_done).detach();
415
416        let q1_pred = q1.forward(t.observations.clone(), t.actions.clone());
417        let q2_pred = q2.forward(t.observations.clone(), t.actions.clone());
418
419        // mean of min(Q1, Q2) over the stored batch actions, for stats —
420        // detached so it does not entangle the loss graph we back-prop.
421        let mean_q_val = min_pair(q1_pred.clone(), q2_pred.clone())
422            .mean()
423            .detach()
424            .into_scalar()
425            .to_f64();
426
427        let critic1_loss = mse(q1_pred, td_target.clone());
428        let critic2_loss = mse(q2_pred, td_target);
429        // Sum the two critic losses and back-prop once. q1 and q2 are
430        // independent modules, so a single `backward()` lets us extract a
431        // disjoint `GradientsParams` for each from the shared gradient
432        // container — this avoids back-propagating twice over graph nodes
433        // (the shared `t.observations` / `t.actions` leaves) that Burn's
434        // graph cleaner would otherwise prune after the first pass.
435        let critic_loss = critic1_loss + critic2_loss;
436        let critic_loss_val = critic_loss.clone().detach().into_scalar().to_f64() / 2.0;
437        if !critic_loss_val.is_finite() {
438            return Err(anyhow!("Non-finite critic loss: {}", critic_loss_val));
439        }
440        let mut critic_grads = critic_loss.backward();
441        let grads1 = GradientsParams::from_module(&mut critic_grads, &q1);
442        let grads2 = GradientsParams::from_module(&mut critic_grads, &q2);
443        q1 = self.critic_opt.step(self.config.critic_lr, q1, grads1);
444        q2 = self.critic_opt.step(self.config.critic_lr, q2, grads2);
445
446        // ----- Actor update -----
447        // Reparameterized action from the current actor; the critics are
448        // frozen (we clone the just-updated online nets and detach their
449        // outputs' graph from the critic params by not collecting their
450        // grads).
451        let (pi_action, pi_log_prob) = actor.sample(t.observations.clone(), &mut self.rng);
452        let q1_pi = q1.forward(t.observations.clone(), pi_action.clone());
453        let q2_pi = q2.forward(t.observations.clone(), pi_action);
454        let min_q_pi = min_pair(q1_pi, q2_pi);
455        // The entropy gap `(log_prob + target_entropy)` is needed (detached)
456        // by the alpha update; snapshot it before the actor backward consumes
457        // the policy graph.
458        let entropy_gap = pi_log_prob.clone().add_scalar(self.target_entropy).detach();
459        // loss = mean(alpha * log_prob - min_q_pi)
460        let actor_loss = (pi_log_prob.mul_scalar(alpha) - min_q_pi).mean();
461        let actor_loss_val = actor_loss.clone().detach().into_scalar().to_f64();
462        if !actor_loss_val.is_finite() {
463            return Err(anyhow!("Non-finite actor loss: {}", actor_loss_val));
464        }
465        let actor_grads = GradientsParams::from_grads(actor_loss.backward(), &actor);
466        actor = self.actor_opt.step(self.config.actor_lr, actor, actor_grads);
467
468        // ----- Alpha (entropy temperature) update -----
469        let mut alpha_loss_val = 0.0;
470        if self.config.auto_alpha {
471            // loss = -mean(log_alpha * (log_prob + target_entropy).detach())
472            let log_alpha_t = log_alpha.value();
473            // Broadcast log_alpha (shape [1]) across the batch entropy gap.
474            let alpha_loss = -(log_alpha_t * entropy_gap).mean();
475            alpha_loss_val = alpha_loss.clone().detach().into_scalar().to_f64();
476            if !alpha_loss_val.is_finite() {
477                return Err(anyhow!("Non-finite alpha loss: {}", alpha_loss_val));
478            }
479            let alpha_grads = GradientsParams::from_grads(alpha_loss.backward(), &log_alpha);
480            log_alpha = self.alpha_opt.step(self.config.alpha_lr, log_alpha, alpha_grads);
481        }
482
483        // ----- Polyak soft target update -----
484        self.q1_target.soft_update_from(&q1, self.config.tau);
485        self.q2_target.soft_update_from(&q2, self.config.tau);
486
487        let alpha_after = log_alpha.alpha_scalar();
488
489        self.actor = Some(actor);
490        self.q1 = Some(q1);
491        self.q2 = Some(q2);
492        self.log_alpha = Some(log_alpha);
493        self.total_train_steps += 1;
494
495        Ok(SacStepStats {
496            critic_loss: critic_loss_val,
497            actor_loss: actor_loss_val,
498            alpha_loss: alpha_loss_val,
499            alpha: alpha_after,
500            mean_q: mean_q_val,
501            buffer_len,
502        })
503    }
504}
505
506/// Build an Adam optimizer for module type `M`, applying the optional
507/// global gradient-norm clip from the config.
508fn build_adam<B, M>(max_grad_norm: Option<f64>) -> SacAdam<B, M>
509where
510    B: AutodiffBackend,
511    M: burn::module::AutodiffModule<B>,
512{
513    let mut cfg = AdamConfig::new();
514    if let Some(norm) = max_grad_norm {
515        cfg = cfg.with_grad_clipping(Some(GradientClippingConfig::Norm(norm as f32)));
516    }
517    cfg.init()
518}
519
520/// Elementwise minimum of two `[batch]` Q-value tensors (clipped
521/// double-Q): `min(a, b) = b + min(a - b, 0)`.
522fn min_pair<B: AutodiffBackend>(a: Tensor<B, 1>, b: Tensor<B, 1>) -> Tensor<B, 1> {
523    let diff = a - b.clone();
524    b + diff.clamp_max(0.0)
525}
526
527/// Mean-squared-error between a prediction and a (detached) target, both
528/// shape `[batch]`.
529fn mse<B: AutodiffBackend>(pred: Tensor<B, 1>, target: Tensor<B, 1>) -> Tensor<B, 1> {
530    let diff = pred - target;
531    (diff.clone() * diff).mean()
532}
533
534#[cfg(test)]
535mod tests {
536    use burn::backend::{Autodiff, NdArray};
537
538    use super::*;
539
540    type B = Autodiff<NdArray<f32>>;
541
542    fn tiny_config() -> SacConfig {
543        SacConfig::new()
544            .buffer_capacity(256)
545            .min_buffer_size(8)
546            .batch_size(8)
547            .learning_starts(4)
548            .hidden_dim(16)
549            .seed(7)
550    }
551
552    fn fill_buffer(trainer: &mut SacTrainer<B>, n: usize) {
553        for i in 0..n {
554            let phase = i as f32 * 0.1;
555            let obs = [phase.cos(), phase.sin(), phase * 0.2];
556            let next_obs = [(phase + 0.1).cos(), (phase + 0.1).sin(), phase * 0.2];
557            let action = [(phase.sin()).clamp(-0.99, 0.99)];
558            let reward = -(phase * phase);
559            let done = i % 5 == 4;
560            trainer.buffer_mut().push(&obs, &action, reward, &next_obs, done);
561        }
562    }
563
564    #[test]
565    fn trainer_constructs_and_copies_targets() {
566        let device = Default::default();
567        let trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
568        assert_eq!(trainer.total_env_steps(), 0);
569        assert_eq!(trainer.buffer_len(), 0);
570        assert_eq!(trainer.target_entropy(), -1.0);
571
572        // Targets start byte-equal to their online critics.
573        let obs = Tensor::<B, 2>::from_data(
574            burn::tensor::TensorData::new(vec![0.1, 0.2, 0.3], [1, 3]),
575            &Default::default(),
576        );
577        let act = Tensor::<B, 2>::from_data(
578            burn::tensor::TensorData::new(vec![0.4], [1, 1]),
579            &Default::default(),
580        );
581        let on: f32 = trainer.q1().forward(obs.clone(), act.clone()).into_scalar().to_f32();
582        let tg: f32 = trainer.q1_target.forward(obs, act).into_scalar().to_f32();
583        assert!((on - tg).abs() < 1e-6, "target must start equal to online critic");
584    }
585
586    #[test]
587    fn rejects_invalid_config() {
588        let device = Default::default();
589        let bad = SacConfig::new().gamma(2.0);
590        assert!(SacTrainer::<B>::new(bad, 3, 1, device).is_err());
591    }
592
593    #[test]
594    fn rejects_zero_dims() {
595        let device = Default::default();
596        assert!(SacTrainer::<B>::new(tiny_config(), 0, 1, device).is_err());
597        let device2 = Default::default();
598        assert!(SacTrainer::<B>::new(tiny_config(), 3, 0, device2).is_err());
599    }
600
601    #[test]
602    fn train_returns_none_until_ready() {
603        let device = Default::default();
604        let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
605        fill_buffer(&mut trainer, 4); // below min_buffer_size (8)
606        assert!(trainer.train().unwrap().is_none());
607    }
608
609    #[test]
610    fn one_train_step_runs_with_finite_losses() {
611        let device = Default::default();
612        let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
613        fill_buffer(&mut trainer, 32);
614        let stats = trainer.train().unwrap().expect("should train once buffer ready");
615        assert!(stats.critic_loss.is_finite(), "critic loss finite");
616        assert!(stats.actor_loss.is_finite(), "actor loss finite");
617        assert!(stats.alpha_loss.is_finite(), "alpha loss finite");
618        assert!(stats.alpha.is_finite() && stats.alpha > 0.0, "alpha positive finite");
619        assert!(stats.mean_q.is_finite(), "mean_q finite");
620        assert_eq!(stats.buffer_len, 32);
621        assert_eq!(trainer.total_train_steps(), 1);
622    }
623
624    #[test]
625    fn fixed_alpha_keeps_alpha_constant() {
626        let device = Default::default();
627        let cfg = tiny_config().auto_alpha(false).init_alpha(0.3);
628        let mut trainer = SacTrainer::<B>::new(cfg, 3, 1, device).unwrap();
629        fill_buffer(&mut trainer, 32);
630        let before = trainer.alpha();
631        for _ in 0..5 {
632            trainer.train().unwrap();
633        }
634        let after = trainer.alpha();
635        assert!((before - after).abs() < 1e-9, "fixed alpha must not move: {before} -> {after}");
636        assert!((after - 0.3).abs() < 1e-6);
637    }
638
639    #[test]
640    fn target_moves_after_step() {
641        let device = Default::default();
642        let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
643        fill_buffer(&mut trainer, 32);
644
645        let obs = Tensor::<B, 2>::from_data(
646            burn::tensor::TensorData::new(vec![0.1, 0.2, 0.3], [1, 3]),
647            &Default::default(),
648        );
649        let act = Tensor::<B, 2>::from_data(
650            burn::tensor::TensorData::new(vec![0.4], [1, 1]),
651            &Default::default(),
652        );
653        let tg_before: f32 =
654            trainer.q1_target.forward(obs.clone(), act.clone()).into_scalar().to_f32();
655        for _ in 0..5 {
656            trainer.train().unwrap();
657        }
658        let tg_after: f32 = trainer.q1_target.forward(obs, act).into_scalar().to_f32();
659        assert!(
660            (tg_before - tg_after).abs() > 1e-7,
661            "soft update should move target: {tg_before} -> {tg_after}"
662        );
663    }
664
665    #[test]
666    fn select_action_warmup_then_policy_in_range() {
667        let device = Default::default();
668        let mut trainer = SacTrainer::<B>::new(tiny_config(), 3, 1, device).unwrap();
669        // Warmup: random in (-1, 1).
670        let a = trainer.select_action(&[0.1, 0.2, 0.3]);
671        assert_eq!(a.len(), 1);
672        assert!(a[0] > -1.0 && a[0] < 1.0);
673        // Advance past learning_starts and sample from the policy.
674        for _ in 0..trainer.config().learning_starts {
675            trainer.increment_env_step();
676        }
677        assert!(!trainer.in_warmup());
678        let a = trainer.select_action(&[0.1, 0.2, 0.3]);
679        assert_eq!(a.len(), 1);
680        assert!(a[0] > -1.0 && a[0] < 1.0);
681        // Deterministic eval action also in range.
682        let e = trainer.eval_action(&[0.1, 0.2, 0.3]);
683        assert!(e[0] > -1.0 && e[0] < 1.0);
684    }
685}