Skip to main content

optirs_core/reinforcement_learning/
policy_gradient.rs

1// Policy Gradient Optimizers
2//
3// This module implements various policy gradient methods including REINFORCE,
4// PPO (Proximal Policy Optimization), TRPO (Trust Region Policy Optimization),
5// and other modern policy gradient algorithms.
6
7use super::{
8    clip_named_gradients, flatten_named, scale_named_gradients, PolicyNetwork,
9    RLOptimizationMetrics, RLOptimizerConfig, RLScheduler, ScheduleType, TrajectoryBatch,
10    TrustRegionConfig, TrustRegionMethod, TrustRegionOptimizer, ValueNetwork,
11};
12use crate::error::{OptimError, Result};
13use scirs2_core::ndarray::{Array1, Array2, ScalarOperand};
14use scirs2_core::numeric::Float;
15use std::fmt::Debug;
16
17/// Policy gradient optimization methods
18#[derive(Debug, Clone, Copy)]
19pub enum PolicyGradientMethod {
20    /// REINFORCE algorithm
21    Reinforce,
22
23    /// Actor-Critic
24    ActorCritic,
25
26    /// Proximal Policy Optimization (PPO) with clipped surrogate
27    PPOClip,
28
29    /// PPO with adaptive KL penalty
30    PPOAdaptiveKL,
31
32    /// Trust Region Policy Optimization (TRPO)
33    TRPO,
34
35    /// Importance Weighted Actor-Learner Architecture (IMPALA)
36    IMPALA,
37
38    /// Asynchronous Advantage Actor-Critic (A3C)
39    A3C,
40}
41
42/// Policy gradient optimizer configuration
43#[derive(Debug, Clone)]
44pub struct PolicyGradientConfig<T: Float + Debug + Send + Sync + 'static> {
45    /// Base RL configuration
46    pub base_config: RLOptimizerConfig<T>,
47
48    /// Policy gradient method
49    pub method: PolicyGradientMethod,
50
51    /// PPO-specific parameters
52    pub ppo_config: PPOConfig<T>,
53
54    /// TRPO-specific parameters
55    pub trpo_config: TRPOConfig<T>,
56
57    /// Learning rate scheduler for policy
58    pub policy_scheduler: Option<RLScheduler<T>>,
59
60    /// Learning rate scheduler for value function
61    pub value_scheduler: Option<RLScheduler<T>>,
62
63    /// Use baseline (value function) for variance reduction
64    pub use_baseline: bool,
65
66    /// Enable importance sampling for off-policy updates
67    pub importance_sampling: bool,
68
69    /// Maximum importance sampling ratio
70    pub max_is_ratio: T,
71}
72
73/// PPO-specific configuration
74#[derive(Debug, Clone)]
75pub struct PPOConfig<T: Float + Debug + Send + Sync + 'static> {
76    /// Clipping parameter
77    pub clip_epsilon: T,
78
79    /// Dual clipping (clip both positive and negative advantages)
80    pub dual_clip: bool,
81
82    /// Value function clipping
83    pub value_clip: bool,
84
85    /// Value clipping range
86    pub value_clip_range: T,
87
88    /// Target KL divergence for adaptive methods
89    pub target_kl: T,
90
91    /// KL coefficient for adaptive penalty
92    pub kl_coeff: T,
93
94    /// KL coefficient adaptation factor
95    pub kl_coeff_adapt_factor: T,
96
97    /// Early stopping based on KL divergence
98    pub early_stop_on_kl: bool,
99}
100
101/// TRPO-specific configuration
102#[derive(Debug, Clone)]
103pub struct TRPOConfig<T: Float + Debug + Send + Sync + 'static> {
104    /// Maximum KL divergence for trust region
105    pub max_kl: T,
106
107    /// Backtracking line search parameters
108    pub backtrack_factor: T,
109    pub max_backtracks: usize,
110
111    /// Conjugate gradient parameters
112    pub cg_iters: usize,
113    pub cg_damping: T,
114    pub cg_tolerance: T,
115
116    /// Use natural gradients
117    pub use_natural_gradients: bool,
118}
119
120impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive> Default
121    for PolicyGradientConfig<T>
122{
123    fn default() -> Self {
124        Self {
125            base_config: RLOptimizerConfig::default(),
126            method: PolicyGradientMethod::PPOClip,
127            ppo_config: PPOConfig::default(),
128            trpo_config: TRPOConfig::default(),
129            policy_scheduler: Some(RLScheduler::new(
130                T::from(3e-4).unwrap_or_else(|| T::zero()),
131                ScheduleType::Constant,
132            )),
133            value_scheduler: Some(RLScheduler::new(
134                T::from(1e-3).unwrap_or_else(|| T::zero()),
135                ScheduleType::Constant,
136            )),
137            use_baseline: true,
138            importance_sampling: false,
139            max_is_ratio: T::from(2.0).unwrap_or_else(|| T::zero()),
140        }
141    }
142}
143
144impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive> Default
145    for PPOConfig<T>
146{
147    fn default() -> Self {
148        Self {
149            clip_epsilon: T::from(0.2).unwrap_or_else(|| T::zero()),
150            dual_clip: false,
151            value_clip: true,
152            value_clip_range: T::from(0.2).unwrap_or_else(|| T::zero()),
153            target_kl: T::from(0.01).unwrap_or_else(|| T::zero()),
154            kl_coeff: T::from(0.2).unwrap_or_else(|| T::zero()),
155            kl_coeff_adapt_factor: T::from(1.5).unwrap_or_else(|| T::zero()),
156            early_stop_on_kl: true,
157        }
158    }
159}
160
161impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive> Default
162    for TRPOConfig<T>
163{
164    fn default() -> Self {
165        Self {
166            max_kl: T::from(0.01).unwrap_or_else(|| T::zero()),
167            backtrack_factor: T::from(0.5).unwrap_or_else(|| T::zero()),
168            max_backtracks: 10,
169            cg_iters: 10,
170            cg_damping: T::from(0.1).unwrap_or_else(|| T::zero()),
171            cg_tolerance: T::from(1e-8).unwrap_or_else(|| T::zero()),
172            use_natural_gradients: true,
173        }
174    }
175}
176
177/// Policy gradient optimizer
178pub struct PolicyGradientOptimizer<
179    T: Float + Debug + Send + Sync + 'static,
180    P: PolicyNetwork<T>,
181    V: ValueNetwork<T>,
182> {
183    /// Configuration
184    config: PolicyGradientConfig<T>,
185
186    /// Policy network
187    policy_network: P,
188
189    /// Value network
190    value_network: Option<V>,
191
192    /// Learning rate schedulers
193    policy_scheduler: Option<RLScheduler<T>>,
194    value_scheduler: Option<RLScheduler<T>>,
195
196    /// Optimization statistics
197    metrics: RLOptimizationMetrics<T>,
198
199    /// Update counter
200    update_count: usize,
201
202    /// KL coefficient for adaptive PPO
203    kl_coeff: T,
204
205    /// Trajectory buffer for batch updates
206    trajectory_buffer: Vec<TrajectoryBatch<T>>,
207
208    /// Maximum buffer size
209    max_buffer_size: usize,
210}
211
212impl<
213        T: Float
214            + Debug
215            + Send
216            + Sync
217            + 'static
218            + ScalarOperand
219            + std::ops::AddAssign
220            + std::iter::Sum
221            + scirs2_core::numeric::FromPrimitive,
222        P: PolicyNetwork<T>,
223        V: ValueNetwork<T>,
224    > PolicyGradientOptimizer<T, P, V>
225{
226    /// Create a new policy gradient optimizer
227    pub fn new(
228        config: PolicyGradientConfig<T>,
229        policy_network: P,
230        value_network: Option<V>,
231    ) -> Self {
232        let kl_coeff = config.ppo_config.kl_coeff;
233        let policy_scheduler = config.policy_scheduler.clone();
234        let value_scheduler = config.value_scheduler.clone();
235
236        Self {
237            config,
238            policy_network,
239            value_network,
240            policy_scheduler,
241            value_scheduler,
242            metrics: RLOptimizationMetrics::default(),
243            update_count: 0,
244            kl_coeff,
245            trajectory_buffer: Vec::new(),
246            max_buffer_size: 1000,
247        }
248    }
249
250    /// Update policy using trajectory data
251    pub fn update(&mut self, trajectory: TrajectoryBatch<T>) -> Result<RLOptimizationMetrics<T>> {
252        match self.config.method {
253            PolicyGradientMethod::PPOClip => self.update_ppo_clip(trajectory),
254            PolicyGradientMethod::PPOAdaptiveKL => self.update_ppo_adaptive_kl(trajectory),
255            PolicyGradientMethod::TRPO => self.update_trpo(trajectory),
256            PolicyGradientMethod::Reinforce => self.update_reinforce(trajectory),
257            PolicyGradientMethod::ActorCritic => self.update_actor_critic(trajectory),
258            // A3C is *asynchronous* A2C: multiple workers each compute an A2C
259            // gradient against a shared set of parameters. The asynchrony /
260            // worker-coordination is an orchestration concern handled outside
261            // this optimizer (by whoever drives the per-worker `update` calls);
262            // the per-update math performed here is identical to synchronous A2C.
263            PolicyGradientMethod::A3C => self.update_actor_critic(trajectory),
264            // IMPALA is off-policy: it corrects for the lag between the behavior
265            // policy that generated the trajectory and the current learner policy
266            // using V-trace truncated importance sampling.
267            PolicyGradientMethod::IMPALA => self.update_impala(trajectory),
268        }
269    }
270
271    /// Bootstrap value `V(s_T)` for the step *after* the final transition.
272    ///
273    /// The successor state is [`TrajectoryBatch::final_observation`], **not** the
274    /// last row of `observations` (which is `s_{T-1}`, the state the final action
275    /// was taken from). Bootstrapping on `s_{T-1}` double-counts the last
276    /// transition and biases every advantage in the batch.
277    ///
278    /// Returns zero when there is no value network or no recorded successor state
279    /// (the batch simply gets no bootstrap, which is the correct behaviour for a
280    /// trajectory that ends on termination).
281    fn bootstrap_next_value(&self, trajectory: &TrajectoryBatch<T>) -> Result<T> {
282        let (Some(value_net), Some(final_obs)) = (
283            self.value_network.as_ref(),
284            trajectory.final_observation.as_ref(),
285        ) else {
286            return Ok(T::zero());
287        };
288
289        let mut batch = Array2::zeros((1, final_obs.len()));
290        batch.row_mut(0).assign(final_obs);
291        Ok(value_net.evaluate_value(&batch)?[0])
292    }
293
294    /// Current policy learning rate (scheduler value when configured).
295    fn policy_lr(&self) -> T {
296        self.policy_scheduler
297            .as_ref()
298            .map(|s| s.get_lr())
299            .unwrap_or(self.config.base_config.policy_lr)
300    }
301
302    /// Current value-function learning rate (scheduler value when configured).
303    fn value_lr(&self) -> T {
304        self.value_scheduler
305            .as_ref()
306            .map(|s| s.get_lr())
307            .unwrap_or(self.config.base_config.value_lr)
308    }
309
310    /// Fill `trajectory.advantages` / `trajectory.returns` with normalized GAE
311    /// estimates, bootstrapping the final step with the current value network.
312    /// This is the exact advantage computation shared by PPO (clipped &
313    /// adaptive-KL) and A2C so the on-policy variants stay consistent.
314    fn prepare_gae(&self, trajectory: &mut TrajectoryBatch<T>) -> Result<()> {
315        let next_value = self.bootstrap_next_value(trajectory)?;
316        trajectory.compute_advantages(
317            self.config.base_config.discount_factor,
318            self.config.base_config.gae_lambda,
319            next_value,
320        )
321    }
322
323    /// PPO with clipped surrogate objective.
324    ///
325    /// Per mini-batch sample the objective is `min(r·A, clip(r, 1±ε)·A)` with
326    /// `r = exp(log π − log π_old)`. Its derivative w.r.t. `log π` is `r·A` when
327    /// the unclipped branch wins, and zero when the clipped branch wins *and* the
328    /// ratio has left the clip interval (where `clip` is flat) — this is exactly
329    /// the "no gradient once you have moved too far" behaviour PPO relies on, and
330    /// it is what gets fed to the policy's score oracle.
331    fn update_ppo_clip(
332        &mut self,
333        mut trajectory: TrajectoryBatch<T>,
334    ) -> Result<RLOptimizationMetrics<T>> {
335        let mut total_policy_loss = T::zero();
336        let mut total_value_loss = T::zero();
337        let mut total_entropy_loss = T::zero();
338        let mut clip_fraction = T::zero();
339        let mut approx_kl = T::zero();
340        // Real count of applied mini-batch updates — early stopping means this is
341        // NOT `n_epochs · ceil(N / batch)`.
342        let mut n_updates = 0usize;
343
344        // Compute advantages using GAE (with value-network bootstrap).
345        self.prepare_gae(&mut trajectory)?;
346
347        let n_epochs = self.config.base_config.n_epochs;
348        let mini_batch_size = self.config.base_config.mini_batchsize;
349        if mini_batch_size == 0 {
350            return Err(OptimError::InvalidConfig(
351                "mini_batchsize must be greater than zero".to_string(),
352            ));
353        }
354
355        let clip_eps = self.config.ppo_config.clip_epsilon;
356        let target_kl = self.config.ppo_config.target_kl;
357        let two = T::one() + T::one();
358        let half = T::one() / two;
359
360        'epochs: for _epoch in 0..n_epochs {
361            for mini_batch in trajectory.get_mini_batches(mini_batch_size) {
362                let batch_len = mini_batch.observations.nrows();
363                if batch_len == 0 {
364                    continue;
365                }
366                let count = T::from(batch_len).ok_or_else(|| {
367                    OptimError::ComputationError(
368                        "failed to convert mini-batch size to scalar".to_string(),
369                    )
370                })?;
371                let inv_n = T::one() / count;
372
373                let policy_eval = self
374                    .policy_network
375                    .evaluate_actions(&mini_batch.observations, &mini_batch.actions)?;
376
377                let log_ratio = &policy_eval.log_probs - &mini_batch.log_probs;
378                let ratio = log_ratio.mapv(|x| x.exp());
379
380                let mut policy_loss = T::zero();
381                let mut dloss_dlogp = Array1::zeros(batch_len);
382                let mut n_clipped = 0usize;
383
384                for i in 0..batch_len {
385                    let r = ratio[i];
386                    let advantage = mini_batch.advantages[i];
387                    let clipped_r = r.max(T::one() - clip_eps).min(T::one() + clip_eps);
388
389                    let surr1 = r * advantage;
390                    let surr2 = clipped_r * advantage;
391
392                    let (objective, dobjective) = if surr1 <= surr2 {
393                        // Unclipped branch: d(r·A)/d log π = r·A.
394                        (surr1, r * advantage)
395                    } else {
396                        // Clipped branch: the gradient survives only while the
397                        // ratio is strictly inside the clip interval.
398                        let inside = r > T::one() - clip_eps && r < T::one() + clip_eps;
399                        (surr2, if inside { r * advantage } else { T::zero() })
400                    };
401
402                    policy_loss = policy_loss - objective * inv_n;
403                    dloss_dlogp[i] = -dobjective * inv_n;
404
405                    if r < T::one() - clip_eps || r > T::one() + clip_eps {
406                        n_clipped += 1;
407                    }
408                }
409
410                let entropy_loss = Self::entropy_loss(&policy_eval);
411
412                // Real gradient steps: policy first (so the value update sees the
413                // same parameters PPO's reference implementations do), then value.
414                self.apply_policy_gradient_step(
415                    &mini_batch.observations,
416                    &mini_batch.actions,
417                    &dloss_dlogp,
418                )?;
419                let value_loss = self.update_value_on_batch(
420                    &mini_batch.observations,
421                    &mini_batch.values,
422                    &mini_batch.returns,
423                )?;
424
425                total_policy_loss += policy_loss;
426                total_value_loss += value_loss;
427                total_entropy_loss += entropy_loss;
428                n_updates += 1;
429
430                clip_fraction += T::from(n_clipped).unwrap_or_else(T::zero) * inv_n;
431
432                // Standard second-order KL estimator: ½·E[(log ratio)²].
433                let batch_kl = half * log_ratio.mapv(|x| x * x).mean().unwrap_or(T::zero());
434                approx_kl += batch_kl;
435
436                // Early stopping compares the *current mini-batch* KL against the
437                // threshold (an accumulated sum would trip on batch count alone)
438                // and abandons the whole update, not just the inner loop.
439                if self.config.ppo_config.early_stop_on_kl && batch_kl > target_kl * two {
440                    break 'epochs;
441                }
442            }
443        }
444
445        // Update learning rates
446        if let Some(ref mut scheduler) = self.policy_scheduler {
447            self.metrics.policy_lr = scheduler.step();
448        }
449        if let Some(ref mut scheduler) = self.value_scheduler {
450            self.metrics.value_lr = scheduler.step();
451        }
452
453        self.update_count += 1;
454
455        let divisor = T::from(n_updates.max(1)).unwrap_or_else(T::one);
456        self.metrics.policy_loss = total_policy_loss / divisor;
457        self.metrics.value_loss = total_value_loss / divisor;
458        self.metrics.entropy_loss = total_entropy_loss / divisor;
459        self.metrics.total_loss = self.metrics.policy_loss
460            + self.config.base_config.value_loss_coeff * self.metrics.value_loss
461            + self.config.base_config.entropy_coeff * self.metrics.entropy_loss;
462        self.metrics.clip_fraction = Some(clip_fraction / divisor);
463        self.metrics.kl_divergence = Some(approx_kl / divisor);
464
465        Ok(self.metrics.clone())
466    }
467
468    /// PPO with adaptive KL penalty.
469    ///
470    /// Mirrors [`Self::update_ppo_clip`]'s mini-batch structure but replaces the
471    /// clipped surrogate with a KL-penalty surrogate:
472    ///
473    /// ```text
474    /// policy_loss = -E[ ratio · advantage ] + β · KL(old‖new)
475    /// ```
476    ///
477    /// where `ratio = exp(new_logp − old_logp)` and the per-sample KL of the old
478    /// policy relative to the new is estimated as `old_logp − new_logp` (the
479    /// standard first-order estimator). After each epoch the penalty coefficient
480    /// `β` (stored on `self.kl_coeff`) is adapted against the configured target
481    /// KL using the canonical PPO rule:
482    ///
483    /// * `KL > 1.5 · target_kl`  ⇒ `β ← 2β`   (penalty too weak)
484    /// * `KL < target_kl / 1.5`  ⇒ `β ← β / 2` (penalty too strong)
485    ///
486    /// `β` is clamped to a sane range so it neither vanishes nor explodes.
487    fn update_ppo_adaptive_kl(
488        &mut self,
489        mut trajectory: TrajectoryBatch<T>,
490    ) -> Result<RLOptimizationMetrics<T>> {
491        let mut total_policy_loss = T::zero();
492        let mut total_value_loss = T::zero();
493        let mut total_entropy_loss = T::zero();
494        let mut approx_kl = T::zero();
495
496        // Target KL for the adaptation rule (reuse the PPO config field).
497        let target_kl = self.config.ppo_config.target_kl;
498        let one_point_five = T::from(1.5).unwrap_or_else(|| T::one());
499        let two = T::from(2.0).unwrap_or_else(|| T::one() + T::one());
500        // Clamp range for β to keep the penalty well-conditioned across updates.
501        let beta_min = T::from(1e-4).unwrap_or_else(|| T::zero());
502        let beta_max = T::from(1e4).unwrap_or_else(|| T::one());
503
504        // Compute advantages using GAE (identical to the clipped variant).
505        self.prepare_gae(&mut trajectory)?;
506
507        let n_epochs = self.config.base_config.n_epochs;
508        let mini_batch_size = self.config.base_config.mini_batchsize;
509        if mini_batch_size == 0 {
510            return Err(OptimError::InvalidConfig(
511                "mini_batchsize must be greater than zero".to_string(),
512            ));
513        }
514
515        // The KL measured on the final epoch drives the β adaptation.
516        let mut last_epoch_kl = T::zero();
517        let mut n_updates = 0usize;
518
519        'epochs: for _epoch in 0..n_epochs {
520            let mini_batches = trajectory.get_mini_batches(mini_batch_size);
521            let mut epoch_kl_sum = T::zero();
522            let mut epoch_batches = T::zero();
523
524            for mini_batch in mini_batches {
525                let batch_len = mini_batch.observations.nrows();
526                if batch_len == 0 {
527                    continue;
528                }
529                let batch_count = T::from(batch_len).ok_or_else(|| {
530                    OptimError::ComputationError(
531                        "failed to convert mini-batch size to scalar".to_string(),
532                    )
533                })?;
534                let inv_n = T::one() / batch_count;
535
536                // Current policy evaluation.
537                let policy_eval = self
538                    .policy_network
539                    .evaluate_actions(&mini_batch.observations, &mini_batch.actions)?;
540
541                // Importance sampling ratio = exp(new_logp − old_logp).
542                let log_ratio = &policy_eval.log_probs - &mini_batch.log_probs;
543                let ratio = log_ratio.mapv(|x| x.exp());
544
545                // Surrogate (un-clipped): E[ ratio · advantage ].
546                let surrogate =
547                    (&ratio * &mini_batch.advantages).iter().copied().sum::<T>() / batch_count;
548
549                // Per-sample KL(old‖new) estimate = old_logp − new_logp = −log_ratio.
550                // Mean over the mini-batch, guarded to be non-negative.
551                let mut kl_sum = T::zero();
552                for &lr in log_ratio.iter() {
553                    kl_sum = kl_sum - lr;
554                }
555                let batch_kl = (kl_sum / batch_count).max(T::zero());
556
557                // KL-penalty surrogate policy loss.
558                let policy_loss = -surrogate + self.kl_coeff * batch_kl;
559
560                // ∂L/∂ log πᵢ = (−rᵢ·Aᵢ − β)/N: the ratio term differentiates to
561                // r·A, the KL estimator (−log ratio) contributes −β.
562                let mut dloss_dlogp = Array1::zeros(batch_len);
563                for i in 0..batch_len {
564                    dloss_dlogp[i] = (-ratio[i] * mini_batch.advantages[i] - self.kl_coeff) * inv_n;
565                }
566
567                let entropy_loss = Self::entropy_loss(&policy_eval);
568
569                self.apply_policy_gradient_step(
570                    &mini_batch.observations,
571                    &mini_batch.actions,
572                    &dloss_dlogp,
573                )?;
574                let value_loss = self.update_value_on_batch(
575                    &mini_batch.observations,
576                    &mini_batch.values,
577                    &mini_batch.returns,
578                )?;
579
580                // Accumulate metrics.
581                total_policy_loss += policy_loss;
582                total_value_loss += value_loss;
583                total_entropy_loss += entropy_loss;
584                n_updates += 1;
585
586                approx_kl += batch_kl;
587                epoch_kl_sum += batch_kl;
588                epoch_batches += T::one();
589
590                // Early stopping based on KL divergence: abandon the whole update.
591                if self.config.ppo_config.early_stop_on_kl
592                    && batch_kl > self.config.ppo_config.target_kl * two
593                {
594                    last_epoch_kl = epoch_kl_sum / epoch_batches;
595                    break 'epochs;
596                }
597            }
598
599            // Mean KL over the epoch's mini-batches (used to adapt β next).
600            if epoch_batches > T::zero() {
601                last_epoch_kl = epoch_kl_sum / epoch_batches;
602            }
603        }
604
605        // Adapt β against the target KL using the canonical PPO rule.
606        if last_epoch_kl > one_point_five * target_kl {
607            self.kl_coeff = self.kl_coeff * two;
608        } else if last_epoch_kl < target_kl / one_point_five {
609            self.kl_coeff = self.kl_coeff / two;
610        }
611        // Clamp β into a sane range.
612        if self.kl_coeff < beta_min {
613            self.kl_coeff = beta_min;
614        } else if self.kl_coeff > beta_max {
615            self.kl_coeff = beta_max;
616        }
617
618        // Update learning rates.
619        if let Some(ref mut scheduler) = self.policy_scheduler {
620            self.metrics.policy_lr = scheduler.step();
621        }
622        if let Some(ref mut scheduler) = self.value_scheduler {
623            self.metrics.value_lr = scheduler.step();
624        }
625
626        self.update_count += 1;
627
628        // Update metrics (averaged over the mini-batch updates actually applied).
629        let divisor = T::from(n_updates.max(1)).unwrap_or_else(T::one);
630        self.metrics.policy_loss = total_policy_loss / divisor;
631        self.metrics.value_loss = total_value_loss / divisor;
632        self.metrics.entropy_loss = total_entropy_loss / divisor;
633        self.metrics.total_loss = self.metrics.policy_loss
634            + self.config.base_config.value_loss_coeff * self.metrics.value_loss
635            + self.config.base_config.entropy_coeff * self.metrics.entropy_loss;
636        // No clipping in this variant.
637        self.metrics.clip_fraction = None;
638        self.metrics.kl_divergence = Some(approx_kl / divisor);
639        // Surface the current penalty coefficient for inspection.
640        self.metrics
641            .custom_metrics
642            .insert("kl_coeff".to_string(), self.kl_coeff);
643
644        Ok(self.metrics.clone())
645    }
646
647    /// TRPO update: a genuine trust-region step, not a PPO alias.
648    ///
649    /// The surrogate gradient `g = ∇_θ E[log π(a|s)·A(s,a)]` and the per-sample
650    /// score matrix are produced by the policy's analytic oracle, then handed to
651    /// [`TrustRegionOptimizer`] configured from this optimizer's [`TRPOConfig`].
652    /// The trust-region optimizer borrows the policy (via the `&mut P` forwarding
653    /// impl), solves `F x = g` with damped conjugate gradients, takes the
654    /// `β = √(2δ / sᵀFs)` step and backtracks against the **real**
655    /// importance-weighted surrogate evaluated on this trajectory.
656    ///
657    /// The value function is fitted by regression on the GAE returns, as in the
658    /// original TRPO.
659    fn update_trpo(
660        &mut self,
661        mut trajectory: TrajectoryBatch<T>,
662    ) -> Result<RLOptimizationMetrics<T>> {
663        self.prepare_gae(&mut trajectory)?;
664
665        let batch_len = trajectory.observations.nrows();
666        if batch_len == 0 {
667            return Err(OptimError::InvalidConfig(
668                "TRPO received an empty trajectory".to_string(),
669            ));
670        }
671        let count = T::from(batch_len).ok_or_else(|| {
672            OptimError::ComputationError("failed to convert batch size to scalar".to_string())
673        })?;
674        let inv_n = T::one() / count;
675
676        // Fit the value baseline first (it does not participate in the trust region).
677        let value_loss = self.update_value_on_batch(
678            &trajectory.observations,
679            &trajectory.values,
680            &trajectory.returns,
681        )?;
682
683        // Ascent direction of the surrogate: ∇_θ (1/N) Σ A_i log π_i.
684        let coefficients = trajectory.advantages.mapv(|a| a * inv_n);
685        let gradient_map = self.policy_network.log_prob_gradient(
686            &trajectory.observations,
687            &trajectory.actions,
688            &coefficients,
689        )?;
690        let flat_gradient = flatten_named(&gradient_map);
691        let grad_norm = flat_gradient.iter().map(|&g| g * g).sum::<T>().sqrt();
692
693        // Empirical Fisher from per-sample scores.
694        let scores = self
695            .policy_network
696            .score_matrix(&trajectory.observations, &trajectory.actions)?;
697
698        // Log-probabilities of the behaviour policy, for the surrogate ratio.
699        let old_log_probs = self
700            .policy_network
701            .evaluate_actions(&trajectory.observations, &trajectory.actions)?
702            .log_probs;
703
704        let trpo = self.config.trpo_config.clone();
705        let tr_config = TrustRegionConfig {
706            method: TrustRegionMethod::TRPO,
707            max_kl: trpo.max_kl,
708            cg_iters: trpo.cg_iters,
709            cg_damping: trpo.cg_damping,
710            cg_tolerance: trpo.cg_tolerance,
711            max_backtracks: trpo.max_backtracks,
712            backtrack_coeff: trpo.backtrack_factor,
713            ..TrustRegionConfig::default()
714        };
715
716        // Owned copies so the surrogate closure never borrows `self`.
717        let observations = trajectory.observations.clone();
718        let actions = trajectory.actions.clone();
719        let advantages = trajectory.advantages.clone();
720
721        let tr_metrics = {
722            let mut trust_region = TrustRegionOptimizer::new(tr_config, &mut self.policy_network);
723            trust_region.set_score_samples(scores);
724            trust_region.update_trpo_with_surrogate(&flat_gradient, |policy| {
725                let evaluation = policy.evaluate_actions(&observations, &actions)?;
726                let mut surrogate = T::zero();
727                for i in 0..batch_len {
728                    let ratio = (evaluation.log_probs[i] - old_log_probs[i]).exp();
729                    surrogate += ratio * advantages[i];
730                }
731                Ok(surrogate * inv_n)
732            })?
733        };
734
735        if let Some(ref mut scheduler) = self.policy_scheduler {
736            self.metrics.policy_lr = scheduler.step();
737        }
738        if let Some(ref mut scheduler) = self.value_scheduler {
739            self.metrics.value_lr = scheduler.step();
740        }
741        self.update_count += 1;
742
743        self.metrics.policy_loss = tr_metrics.policy_loss;
744        self.metrics.value_loss = value_loss;
745        self.metrics.entropy_loss = T::zero();
746        self.metrics.total_loss =
747            self.metrics.policy_loss + self.config.base_config.value_loss_coeff * value_loss;
748        self.metrics.clip_fraction = None;
749        self.metrics.kl_divergence = tr_metrics.kl_divergence;
750        self.metrics.policy_grad_norm = grad_norm;
751        for (name, value) in tr_metrics.custom_metrics {
752            self.metrics.custom_metrics.insert(name, value);
753        }
754
755        Ok(self.metrics.clone())
756    }
757
758    /// REINFORCE (Monte-Carlo policy gradient).
759    ///
760    /// The advantages/returns the loss needs are **computed here** rather than
761    /// read out of a freshly zeroed [`TrajectoryBatch`] (which made the loss
762    /// identically zero, and therefore the update a no-op):
763    ///
764    /// * with a value baseline → GAE advantages (plus a value regression step),
765    /// * without one → plain discounted Monte-Carlo returns `G_t`.
766    fn update_reinforce(
767        &mut self,
768        mut trajectory: TrajectoryBatch<T>,
769    ) -> Result<RLOptimizationMetrics<T>> {
770        let batch_len = trajectory.observations.nrows();
771        if batch_len == 0 {
772            return Err(OptimError::InvalidConfig(
773                "REINFORCE received an empty trajectory".to_string(),
774            ));
775        }
776        let count = T::from(batch_len).ok_or_else(|| {
777            OptimError::ComputationError("failed to convert batch size to scalar".to_string())
778        })?;
779        let inv_n = T::one() / count;
780
781        let use_baseline = self.config.use_baseline && self.value_network.is_some();
782        let weights = if use_baseline {
783            self.prepare_gae(&mut trajectory)?;
784            trajectory.advantages.clone()
785        } else {
786            let next_value = self.bootstrap_next_value(&trajectory)?;
787            trajectory
788                .compute_discounted_returns(self.config.base_config.discount_factor, next_value)?;
789            trajectory.returns.clone()
790        };
791
792        let policy_eval = self
793            .policy_network
794            .evaluate_actions(&trajectory.observations, &trajectory.actions)?;
795
796        // L = −(1/N) Σ log π_i · w_i  ⇒  ∂L/∂ log π_i = −w_i / N.
797        let mut policy_loss = T::zero();
798        let mut dloss_dlogp = Array1::zeros(batch_len);
799        for i in 0..batch_len {
800            policy_loss = policy_loss - policy_eval.log_probs[i] * weights[i] * inv_n;
801            dloss_dlogp[i] = -weights[i] * inv_n;
802        }
803
804        let entropy_loss = Self::entropy_loss(&policy_eval);
805
806        self.apply_policy_gradient_step(
807            &trajectory.observations,
808            &trajectory.actions,
809            &dloss_dlogp,
810        )?;
811
812        let value_loss = if use_baseline {
813            self.update_value_on_batch(
814                &trajectory.observations,
815                &trajectory.values,
816                &trajectory.returns,
817            )?
818        } else {
819            T::zero()
820        };
821
822        if let Some(ref mut scheduler) = self.policy_scheduler {
823            self.metrics.policy_lr = scheduler.step();
824        }
825        if let Some(ref mut scheduler) = self.value_scheduler {
826            self.metrics.value_lr = scheduler.step();
827        }
828        self.update_count += 1;
829
830        self.metrics.policy_loss = policy_loss;
831        self.metrics.value_loss = value_loss;
832        self.metrics.entropy_loss = entropy_loss;
833        self.metrics.total_loss = policy_loss
834            + self.config.base_config.value_loss_coeff * value_loss
835            + self.config.base_config.entropy_coeff * entropy_loss;
836        self.metrics.clip_fraction = None;
837        self.metrics.kl_divergence = Some(T::zero());
838
839        Ok(self.metrics.clone())
840    }
841
842    /// Synchronous Advantage Actor-Critic (A2C) update.
843    ///
844    /// A2C is the on-policy special case of the actor-critic family: the
845    /// trajectory was generated by the *current* policy, so the importance
846    /// ratio is identically 1 and there is neither clipping nor multiple
847    /// epochs (contrast with [`Self::update_ppo_clip`]). A single pass over the
848    /// batch is performed:
849    ///
850    /// ```text
851    /// policy_loss = -E[ log π(a|s) · A(s,a) ] - entropy_coeff · entropy
852    /// value_loss  = value_loss_coeff · MSE(V(s), returns)
853    /// ```
854    ///
855    /// where `A(s,a)` are the GAE advantages (normalized, exactly as PPO
856    /// computes them via [`Self::prepare_gae`]) and `returns` are the
857    /// (un-normalized) GAE returns. The advantage and value-clipping treatment
858    /// mirror [`Self::update_ppo_clip`] so the on-policy variants stay
859    /// consistent; the value-clip toggle is honoured identically.
860    fn update_actor_critic(
861        &mut self,
862        mut trajectory: TrajectoryBatch<T>,
863    ) -> Result<RLOptimizationMetrics<T>> {
864        // GAE advantages + returns (normalized advantages, value bootstrap) —
865        // shared with PPO so A2C and PPO agree on the advantage definition.
866        self.prepare_gae(&mut trajectory)?;
867
868        // Single on-policy pass: evaluate the current policy on the whole batch.
869        let policy_eval = self
870            .policy_network
871            .evaluate_actions(&trajectory.observations, &trajectory.actions)?;
872
873        let batch_len = trajectory.observations.nrows();
874        if batch_len == 0 {
875            return Err(OptimError::InvalidConfig(
876                "A2C received an empty trajectory".to_string(),
877            ));
878        }
879        let batch_count = T::from(batch_len).ok_or_else(|| {
880            OptimError::ComputationError("failed to convert batch size to scalar".to_string())
881        })?;
882        let inv_n = T::one() / batch_count;
883
884        // Policy loss = -E[ log π(a|s) · A(s,a) ]. The importance ratio is 1
885        // (on-policy), so this is the plain advantage-weighted log-likelihood, and
886        // ∂L/∂ log π_i = −A_i / N.
887        let mut policy_loss = T::zero();
888        let mut dloss_dlogp = Array1::zeros(batch_len);
889        for i in 0..batch_len {
890            policy_loss = policy_loss - policy_eval.log_probs[i] * trajectory.advantages[i] * inv_n;
891            dloss_dlogp[i] = -trajectory.advantages[i] * inv_n;
892        }
893
894        // Entropy loss (negative to encourage exploration), same convention as
895        // the PPO paths so `entropy_coeff` behaves identically.
896        let entropy_loss = Self::entropy_loss(&policy_eval);
897
898        // Apply the real policy gradient, then fit the value function against the
899        // GAE returns (identical clipped / unclipped MSE treatment as PPO).
900        self.apply_policy_gradient_step(
901            &trajectory.observations,
902            &trajectory.actions,
903            &dloss_dlogp,
904        )?;
905        let value_loss = self.update_value_on_batch(
906            &trajectory.observations,
907            &trajectory.values,
908            &trajectory.returns,
909        )?;
910
911        // Total loss combines policy, value and entropy contributions exactly
912        // as the PPO variants do.
913        let total_loss = policy_loss
914            + self.config.base_config.value_loss_coeff * value_loss
915            + self.config.base_config.entropy_coeff * entropy_loss;
916
917        // Step learning-rate schedulers (parity with PPO).
918        if let Some(ref mut scheduler) = self.policy_scheduler {
919            self.metrics.policy_lr = scheduler.step();
920        }
921        if let Some(ref mut scheduler) = self.value_scheduler {
922            self.metrics.value_lr = scheduler.step();
923        }
924
925        self.update_count += 1;
926
927        // Populate metrics. A2C performs no clipping and (by construction) has a
928        // unit importance ratio, so there is no clip fraction and the policy-KL
929        // is zero relative to the data-generating policy.
930        self.metrics.policy_loss = policy_loss;
931        self.metrics.value_loss = value_loss;
932        self.metrics.entropy_loss = entropy_loss;
933        self.metrics.total_loss = total_loss;
934        self.metrics.clip_fraction = None;
935        self.metrics.kl_divergence = Some(T::zero());
936
937        Ok(self.metrics.clone())
938    }
939
940    /// IMPALA update via V-trace off-policy correction.
941    ///
942    /// Unlike A2C/PPO, IMPALA is *off-policy*: the trajectory was produced by a
943    /// (possibly lagged) behavior policy `μ` whose log-probabilities are stored
944    /// in `trajectory.log_probs`, while gradients are taken w.r.t. the current
945    /// learner policy `π` (from [`PolicyNetwork::evaluate_actions`]). The lag is
946    /// corrected with truncated importance weights:
947    ///
948    /// ```text
949    /// is_t = exp(log π(a_t|s_t) − log μ(a_t|s_t))
950    /// ρ_t  = min(ρ̄, is_t)          c_t = min(c̄, is_t)
951    /// δ_t  = ρ_t (r_t + γ V(s_{t+1}) − V(s_t))
952    /// v_t  = V(s_t) + δ_t + γ c_t (v_{t+1} − V(s_{t+1}))      (recursion, t = T-1 … 0)
953    /// ```
954    ///
955    /// The V-trace targets `v_t` are the value regression targets, and the
956    /// policy-gradient advantage uses the bootstrapped next target:
957    /// `A_t = ρ_t (r_t + γ v_{t+1} − V(s_t))`. Following the IMPALA paper the
958    /// truncation thresholds satisfy `c̄ ≤ ρ̄`; `ρ̄` is taken from
959    /// `config.max_is_ratio` (canonical default 1.0–2.0) and `c̄ = min(1, ρ̄)`.
960    ///
961    /// `V(s_t)` is evaluated with the *current* learner value network (not the
962    /// behavior-time `trajectory.values`), and the final step is bootstrapped
963    /// with [`Self::bootstrap_next_value`]. When no value network is configured
964    /// V-trace degenerates to truncated-importance-weighted REINFORCE.
965    fn update_impala(
966        &mut self,
967        trajectory: TrajectoryBatch<T>,
968    ) -> Result<RLOptimizationMetrics<T>> {
969        let batch_size = trajectory.observations.nrows();
970        if batch_size == 0 {
971            return Err(OptimError::InvalidConfig(
972                "IMPALA received an empty trajectory".to_string(),
973            ));
974        }
975
976        let gamma = self.config.base_config.discount_factor;
977
978        // Truncation thresholds. ρ̄ from config (clamped to ≥ 1 so the
979        // correction never *down*-weights an unlagged sample); c̄ ≤ ρ̄.
980        let rho_bar = self.config.max_is_ratio.max(T::one());
981        let c_bar = rho_bar.min(T::one());
982
983        // Current learner value estimates V(s_t) and the bootstrap V(s_T).
984        let values_now = if let Some(ref value_net) = self.value_network {
985            value_net.evaluate_value(&trajectory.observations)?
986        } else {
987            Array1::zeros(batch_size)
988        };
989        let bootstrap_value = self.bootstrap_next_value(&trajectory)?;
990
991        // Current learner policy log-probs log π(a_t|s_t) and entropy.
992        let policy_eval = self
993            .policy_network
994            .evaluate_actions(&trajectory.observations, &trajectory.actions)?;
995
996        // Per-step truncated importance weights ρ_t and c_t.
997        let mut rho = Array1::zeros(batch_size);
998        let mut c_trace = Array1::zeros(batch_size);
999        for t in 0..batch_size {
1000            let is_ratio = (policy_eval.log_probs[t] - trajectory.log_probs[t]).exp();
1001            rho[t] = is_ratio.min(rho_bar);
1002            c_trace[t] = is_ratio.min(c_bar);
1003        }
1004
1005        // V-trace targets v_t via the backward recursion, plus the per-step
1006        // policy-gradient advantage A_t = ρ_t (r_t + γ v_{t+1} − V(s_t)).
1007        let mut vtrace_targets = Array1::zeros(batch_size);
1008        let mut pg_advantages = Array1::zeros(batch_size);
1009        // v_{t+1} for the last step is the bootstrap value V(s_T).
1010        let mut next_vtrace = bootstrap_value;
1011        for t in (0..batch_size).rev() {
1012            let is_terminal = trajectory.dones[t];
1013            let nonterminal = T::from(!is_terminal as u8).unwrap_or_else(T::zero);
1014
1015            // V(s_{t+1}): bootstrap for the final step, otherwise the learner's
1016            // value at t+1. Masked to zero on episode termination.
1017            let next_value = if t == batch_size - 1 {
1018                bootstrap_value
1019            } else {
1020                values_now[t + 1]
1021            } * nonterminal;
1022
1023            // δ_t^V = ρ_t (r_t + γ V(s_{t+1}) − V(s_t)).
1024            let delta = rho[t] * (trajectory.rewards[t] + gamma * next_value - values_now[t]);
1025
1026            // v_t = V(s_t) + δ_t + γ c_t (v_{t+1} − V(s_{t+1})).
1027            let masked_next_vtrace = next_vtrace * nonterminal;
1028            let vtrace =
1029                values_now[t] + delta + gamma * c_trace[t] * (masked_next_vtrace - next_value);
1030            vtrace_targets[t] = vtrace;
1031
1032            // Policy-gradient advantage uses the *bootstrapped* next target.
1033            pg_advantages[t] =
1034                rho[t] * (trajectory.rewards[t] + gamma * masked_next_vtrace - values_now[t]);
1035
1036            next_vtrace = vtrace;
1037        }
1038
1039        let batch_count = T::from(batch_size).ok_or_else(|| {
1040            OptimError::ComputationError("failed to convert batch size to scalar".to_string())
1041        })?;
1042        let inv_n = T::one() / batch_count;
1043
1044        // Policy loss = -E[ log π(a_t|s_t) · A_t ] (ρ is already folded into A_t),
1045        // so ∂L/∂ log π_t = −A_t / N.
1046        let mut policy_loss = T::zero();
1047        let mut dloss_dlogp = Array1::zeros(batch_size);
1048        for t in 0..batch_size {
1049            policy_loss = policy_loss - policy_eval.log_probs[t] * pg_advantages[t] * inv_n;
1050            dloss_dlogp[t] = -pg_advantages[t] * inv_n;
1051        }
1052
1053        // Entropy loss (same convention as the other update rules).
1054        let entropy_loss = Self::entropy_loss(&policy_eval);
1055
1056        // Value loss = MSE(V(s_t), v_t) against the V-trace targets, which are
1057        // treated as constants (standard V-trace: the recursion is not
1058        // differentiated through).
1059        let two = T::one() + T::one();
1060        let mut value_loss = T::zero();
1061        let mut dloss_dv = Array1::zeros(batch_size);
1062        if self.value_network.is_some() {
1063            for t in 0..batch_size {
1064                let err = values_now[t] - vtrace_targets[t];
1065                value_loss += err * err * inv_n;
1066                dloss_dv[t] = two * err * inv_n * self.config.base_config.value_loss_coeff;
1067            }
1068        }
1069
1070        // Total loss.
1071        let total_loss = policy_loss
1072            + self.config.base_config.value_loss_coeff * value_loss
1073            + self.config.base_config.entropy_coeff * entropy_loss;
1074
1075        // Apply real policy & value gradient steps.
1076        self.apply_policy_gradient_step(
1077            &trajectory.observations,
1078            &trajectory.actions,
1079            &dloss_dlogp,
1080        )?;
1081        if self.value_network.is_some() {
1082            self.apply_value_gradient_step(&trajectory.observations, &dloss_dv)?;
1083        }
1084
1085        // Step learning-rate schedulers (parity with the other update rules).
1086        if let Some(ref mut scheduler) = self.policy_scheduler {
1087            self.metrics.policy_lr = scheduler.step();
1088        }
1089        if let Some(ref mut scheduler) = self.value_scheduler {
1090            self.metrics.value_lr = scheduler.step();
1091        }
1092
1093        self.update_count += 1;
1094
1095        // Populate metrics. The mean truncated importance weight is surfaced as
1096        // a custom metric for off-policy diagnostics; approx-KL between μ and π
1097        // is the mean log-ratio magnitude.
1098        let mean_rho = rho.iter().copied().sum::<T>() / batch_count;
1099        let approx_kl = (&policy_eval.log_probs - &trajectory.log_probs)
1100            .mapv(|x| x * x)
1101            .mean()
1102            .unwrap_or(T::zero());
1103
1104        self.metrics.policy_loss = policy_loss;
1105        self.metrics.value_loss = value_loss;
1106        self.metrics.entropy_loss = entropy_loss;
1107        self.metrics.total_loss = total_loss;
1108        self.metrics.clip_fraction = None;
1109        self.metrics.kl_divergence = Some(approx_kl);
1110        self.metrics
1111            .custom_metrics
1112            .insert("mean_rho".to_string(), mean_rho);
1113
1114        Ok(self.metrics.clone())
1115    }
1116
1117    /// Apply one gradient-**descent** step to the policy network.
1118    ///
1119    /// `dloss_dlogp[i] = ∂L/∂ log π(aᵢ|sᵢ)` — the only thing that differs between
1120    /// REINFORCE, A2C, PPO-clip, PPO adaptive-KL and V-trace. The chain rule then
1121    /// gives the parameter gradient through the policy's analytic score oracle:
1122    ///
1123    /// ```text
1124    /// ∇_θ L = Σᵢ (∂L/∂log πᵢ)·∇_θ log πᵢ  −  entropy_coeff · ∇_θ H̄
1125    /// ```
1126    ///
1127    /// (the entropy term enters with a minus sign because the reported
1128    /// `entropy_loss` is `−H̄`). The result is globally norm-clipped, recorded in
1129    /// the metrics, scaled by the current learning rate and **negated** before
1130    /// being handed to the network — parameters move *down* the loss, and the
1131    /// learning rate is genuinely applied.
1132    fn apply_policy_gradient_step(
1133        &mut self,
1134        observations: &Array2<T>,
1135        actions: &Array2<T>,
1136        dloss_dlogp: &Array1<T>,
1137    ) -> Result<()> {
1138        let mut gradients =
1139            self.policy_network
1140                .log_prob_gradient(observations, actions, dloss_dlogp)?;
1141
1142        let entropy_coeff = self.config.base_config.entropy_coeff;
1143        if entropy_coeff != T::zero() {
1144            let entropy_grad = self.policy_network.entropy_gradient(observations)?;
1145            for (name, grad) in entropy_grad {
1146                match gradients.get_mut(&name) {
1147                    Some(target) => {
1148                        if target.len() != grad.len() {
1149                            return Err(OptimError::DimensionMismatch(format!(
1150                                "entropy gradient for '{name}' has length {} but the log-prob \
1151                                 gradient has length {}",
1152                                grad.len(),
1153                                target.len()
1154                            )));
1155                        }
1156                        for i in 0..target.len() {
1157                            target[i] = target[i] - entropy_coeff * grad[i];
1158                        }
1159                    }
1160                    None => {
1161                        gradients.insert(name, grad.mapv(|g| -entropy_coeff * g));
1162                    }
1163                }
1164            }
1165        }
1166
1167        let (clipped, norm) =
1168            clip_named_gradients(&gradients, self.config.base_config.max_grad_norm);
1169        self.metrics.policy_grad_norm = norm;
1170
1171        let step = scale_named_gradients(&clipped, -self.policy_lr());
1172        self.policy_network.update_parameters(&step)
1173    }
1174
1175    /// Apply one gradient-descent step to the value network.
1176    ///
1177    /// `dloss_dv[i] = ∂L/∂V(sᵢ)`, already multiplied by `value_loss_coeff`.
1178    /// A no-op when no value network is configured.
1179    fn apply_value_gradient_step(
1180        &mut self,
1181        observations: &Array2<T>,
1182        dloss_dv: &Array1<T>,
1183    ) -> Result<()> {
1184        let max_norm = self.config.base_config.max_grad_norm;
1185        let lr = self.value_lr();
1186
1187        let gradients = match self.value_network {
1188            Some(ref value_net) => value_net.value_gradient(observations, dloss_dv)?,
1189            None => return Ok(()),
1190        };
1191
1192        let (clipped, norm) = clip_named_gradients(&gradients, max_norm);
1193        self.metrics.value_grad_norm = norm;
1194
1195        let step = scale_named_gradients(&clipped, -lr);
1196        if let Some(ref mut value_net) = self.value_network {
1197            value_net.update_parameters(&step)?;
1198        }
1199        Ok(())
1200    }
1201
1202    /// Value loss together with its per-sample derivative `∂L/∂V(sᵢ)`.
1203    ///
1204    /// Honours the PPO value-clipping toggle: the pessimistic `max` of the raw and
1205    /// clipped squared errors is used, and the derivative follows whichever branch
1206    /// the `max` selected (zero when the clipped branch wins *and* the prediction
1207    /// has left the clip interval, exactly like the clipped policy objective).
1208    fn value_loss_and_grad(
1209        &self,
1210        predicted: &Array1<T>,
1211        old_values: &Array1<T>,
1212        returns: &Array1<T>,
1213    ) -> Result<(T, Array1<T>)> {
1214        let n = predicted.len();
1215        if n == 0 {
1216            return Ok((T::zero(), Array1::zeros(0)));
1217        }
1218        if old_values.len() != n || returns.len() != n {
1219            return Err(OptimError::DimensionMismatch(
1220                "value prediction, old value and return batches must have equal length".to_string(),
1221            ));
1222        }
1223
1224        let count = T::from(n).ok_or_else(|| {
1225            OptimError::ComputationError("failed to convert batch size to scalar".to_string())
1226        })?;
1227        let inv_n = T::one() / count;
1228        let two = T::one() + T::one();
1229
1230        let mut loss = T::zero();
1231        let mut grad = Array1::zeros(n);
1232
1233        if self.config.ppo_config.value_clip {
1234            let clip_range = self.config.ppo_config.value_clip_range;
1235            for i in 0..n {
1236                let diff = predicted[i] - old_values[i];
1237                let clamped = diff.max(-clip_range).min(clip_range);
1238                let clipped_pred = old_values[i] + clamped;
1239
1240                let raw_err = predicted[i] - returns[i];
1241                let clipped_err = clipped_pred - returns[i];
1242                let l1 = raw_err * raw_err;
1243                let l2 = clipped_err * clipped_err;
1244
1245                if l1 >= l2 {
1246                    loss += l1 * inv_n;
1247                    grad[i] = two * raw_err * inv_n;
1248                } else {
1249                    loss += l2 * inv_n;
1250                    // d clipped_pred / d predicted is 1 inside the clip interval, 0 outside.
1251                    let inside = diff.abs() < clip_range;
1252                    grad[i] = if inside {
1253                        two * clipped_err * inv_n
1254                    } else {
1255                        T::zero()
1256                    };
1257                }
1258            }
1259        } else {
1260            for i in 0..n {
1261                let err = predicted[i] - returns[i];
1262                loss += err * err * inv_n;
1263                grad[i] = two * err * inv_n;
1264            }
1265        }
1266
1267        Ok((loss, grad))
1268    }
1269
1270    /// Run the value regression step for a batch, returning the value loss.
1271    ///
1272    /// Evaluates the critic, forms the (optionally clipped) squared-error loss and
1273    /// its derivative, and applies a real gradient step scaled by
1274    /// `value_loss_coeff`.
1275    fn update_value_on_batch(
1276        &mut self,
1277        observations: &Array2<T>,
1278        old_values: &Array1<T>,
1279        returns: &Array1<T>,
1280    ) -> Result<T> {
1281        let predicted = match self.value_network {
1282            Some(ref value_net) => value_net.evaluate_value(observations)?,
1283            None => return Ok(T::zero()),
1284        };
1285
1286        let (loss, grad) = self.value_loss_and_grad(&predicted, old_values, returns)?;
1287        let coeff = self.config.base_config.value_loss_coeff;
1288        let scaled = grad.mapv(|g| g * coeff);
1289        self.apply_value_gradient_step(observations, &scaled)?;
1290        Ok(loss)
1291    }
1292
1293    /// Mean entropy loss (`−H̄`) of a policy evaluation.
1294    fn entropy_loss(evaluation: &super::PolicyEvaluation<T>) -> T {
1295        let len = evaluation.entropy.len();
1296        if len == 0 {
1297            return T::zero();
1298        }
1299        let count = T::from(len).unwrap_or_else(T::one);
1300        -evaluation.entropy.iter().copied().sum::<T>() / count
1301    }
1302
1303    /// Get current optimization metrics
1304    pub fn get_metrics(&self) -> &RLOptimizationMetrics<T> {
1305        &self.metrics
1306    }
1307
1308    /// Borrow the policy network (e.g. to roll out the current policy between
1309    /// updates). The optimizer applies updates in place, so this always reflects
1310    /// the latest parameters.
1311    pub fn policy_network(&self) -> &P {
1312        &self.policy_network
1313    }
1314
1315    /// Borrow the value network, when one is configured.
1316    pub fn value_network(&self) -> Option<&V> {
1317        self.value_network.as_ref()
1318    }
1319
1320    /// Add trajectory to buffer
1321    pub fn add_trajectory(&mut self, trajectory: TrajectoryBatch<T>) {
1322        self.trajectory_buffer.push(trajectory);
1323        if self.trajectory_buffer.len() > self.max_buffer_size {
1324            self.trajectory_buffer.remove(0);
1325        }
1326    }
1327
1328    /// Update using buffered trajectories
1329    pub fn update_from_buffer(&mut self) -> Result<RLOptimizationMetrics<T>> {
1330        if self.trajectory_buffer.is_empty() {
1331            return Err(OptimError::InvalidConfig(
1332                "No trajectories in buffer".to_string(),
1333            ));
1334        }
1335
1336        // Combine all trajectories
1337        let combined = self.combine_trajectories()?;
1338        self.update(combined)
1339    }
1340
1341    /// Combine multiple trajectories into one batch
1342    fn combine_trajectories(&self) -> Result<TrajectoryBatch<T>> {
1343        if self.trajectory_buffer.is_empty() {
1344            return Err(OptimError::InvalidConfig(
1345                "No trajectories to combine".to_string(),
1346            ));
1347        }
1348
1349        let total_size: usize = self
1350            .trajectory_buffer
1351            .iter()
1352            .map(|t| t.observations.nrows())
1353            .sum();
1354
1355        let obs_dim = self.trajectory_buffer[0].observations.ncols();
1356        let action_dim = self.trajectory_buffer[0].actions.ncols();
1357
1358        let mut combined_obs = Array2::zeros((total_size, obs_dim));
1359        let mut combined_actions = Array2::zeros((total_size, action_dim));
1360        let mut combined_log_probs = Array1::zeros(total_size);
1361        let mut combined_rewards = Array1::zeros(total_size);
1362        let mut combined_values = Array1::zeros(total_size);
1363        let mut combined_dones = Vec::with_capacity(total_size);
1364
1365        let mut offset = 0;
1366        for trajectory in &self.trajectory_buffer {
1367            let size = trajectory.observations.nrows();
1368
1369            combined_obs
1370                .slice_mut(s![offset..offset + size, ..])
1371                .assign(&trajectory.observations);
1372            combined_actions
1373                .slice_mut(s![offset..offset + size, ..])
1374                .assign(&trajectory.actions);
1375            combined_log_probs
1376                .slice_mut(s![offset..offset + size])
1377                .assign(&trajectory.log_probs);
1378            combined_rewards
1379                .slice_mut(s![offset..offset + size])
1380                .assign(&trajectory.rewards);
1381            combined_values
1382                .slice_mut(s![offset..offset + size])
1383                .assign(&trajectory.values);
1384
1385            // `as_slice` returns None for any non-contiguous (sliced/strided)
1386            // array, so iterate instead of unwrapping a layout assumption.
1387            combined_dones.extend(trajectory.dones.iter().copied());
1388
1389            offset += size;
1390        }
1391
1392        let combined_dones_array = Array1::from_vec(combined_dones);
1393
1394        let combined = TrajectoryBatch::new(
1395            combined_obs,
1396            combined_actions,
1397            combined_log_probs,
1398            combined_rewards,
1399            combined_values,
1400            combined_dones_array,
1401        )?;
1402
1403        // The combined batch ends where the last buffered trajectory ended.
1404        match self
1405            .trajectory_buffer
1406            .last()
1407            .and_then(|t| t.final_observation.clone())
1408        {
1409            Some(final_observation) => combined.with_final_observation(final_observation),
1410            None => Ok(combined),
1411        }
1412    }
1413
1414    /// Clear trajectory buffer
1415    pub fn clear_buffer(&mut self) {
1416        self.trajectory_buffer.clear();
1417    }
1418}
1419
1420// Import slice syntax
1421use scirs2_core::ndarray::s;
1422// use statrs::statistics::Statistics; // statrs not available
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::super::{
1427        ActionDistribution, DistributionType, PolicyEvaluation, RLOptimizerConfig, TrajectoryBatch,
1428        ValueNetwork,
1429    };
1430    use super::*;
1431    use scirs2_core::ndarray::{arr1, arr2, Array1, Array2};
1432    use std::collections::HashMap;
1433
1434    /// Mock policy whose log-probability *is* its single parameter `w[0]`.
1435    ///
1436    /// That makes it genuinely differentiable — `∂ log π/∂w = 1` — so the real
1437    /// gradient path can be exercised end to end while the log-ratio against the
1438    /// trajectory's stored (zero) log-probs stays exactly `w[0]`, letting tests
1439    /// drive the adaptive-KL coefficient up or down.
1440    struct MockPolicy {
1441        params: HashMap<String, Array1<f64>>,
1442        entropy: f64,
1443        /// Number of times `update_parameters` has been invoked.
1444        update_calls: usize,
1445    }
1446
1447    impl MockPolicy {
1448        fn new(log_prob_offset: f64) -> Self {
1449            let mut params = HashMap::new();
1450            params.insert("w".to_string(), arr1(&[log_prob_offset]));
1451            Self {
1452                params,
1453                entropy: 0.5,
1454                update_calls: 0,
1455            }
1456        }
1457
1458        fn offset(&self) -> f64 {
1459            self.params["w"][0]
1460        }
1461    }
1462
1463    impl PolicyNetwork<f64> for MockPolicy {
1464        fn evaluate_actions(
1465            &self,
1466            observations: &Array2<f64>,
1467            _actions: &Array2<f64>,
1468        ) -> Result<PolicyEvaluation<f64>> {
1469            let n = observations.nrows();
1470            let log_probs = Array1::from_elem(n, self.offset());
1471            let entropy = Array1::from_elem(n, self.entropy);
1472            Ok(PolicyEvaluation {
1473                log_probs,
1474                entropy,
1475                metrics: HashMap::new(),
1476            })
1477        }
1478
1479        fn get_action_distribution(
1480            &self,
1481            _observations: &Array2<f64>,
1482        ) -> Result<ActionDistribution<f64>> {
1483            Ok(ActionDistribution {
1484                mean: None,
1485                std: None,
1486                logits: None,
1487                distribution_type: DistributionType::Gaussian,
1488            })
1489        }
1490
1491        fn update_parameters(&mut self, deltas: &HashMap<String, Array1<f64>>) -> Result<()> {
1492            self.update_calls += 1;
1493            for (key, delta) in deltas {
1494                if let Some(p) = self.params.get_mut(key) {
1495                    if p.len() == delta.len() {
1496                        *p = &*p + delta;
1497                    }
1498                }
1499            }
1500            Ok(())
1501        }
1502
1503        fn get_parameters(&self) -> HashMap<String, Array1<f64>> {
1504            self.params.clone()
1505        }
1506
1507        fn log_prob_gradient(
1508            &self,
1509            _observations: &Array2<f64>,
1510            _actions: &Array2<f64>,
1511            coefficients: &Array1<f64>,
1512        ) -> Result<HashMap<String, Array1<f64>>> {
1513            // log π_i = w[0] ⇒ ∂/∂w Σ c_i log π_i = Σ c_i.
1514            let mut map = HashMap::new();
1515            map.insert("w".to_string(), arr1(&[coefficients.iter().sum::<f64>()]));
1516            Ok(map)
1517        }
1518
1519        fn entropy_gradient(
1520            &self,
1521            _observations: &Array2<f64>,
1522        ) -> Result<HashMap<String, Array1<f64>>> {
1523            // Entropy is a constant here, so its gradient is exactly zero.
1524            let mut map = HashMap::new();
1525            map.insert("w".to_string(), arr1(&[0.0]));
1526            Ok(map)
1527        }
1528    }
1529
1530    /// Bias-only linear value function `V(s) = v[0]`: constant in the state but
1531    /// genuinely differentiable (`∂V/∂v = 1`), so value updates are real.
1532    struct MockValue {
1533        params: HashMap<String, Array1<f64>>,
1534        /// Number of times `update_parameters` has been invoked.
1535        update_calls: usize,
1536    }
1537
1538    impl MockValue {
1539        fn new() -> Self {
1540            Self::with_value(0.0)
1541        }
1542
1543        fn with_value(value: f64) -> Self {
1544            let mut params = HashMap::new();
1545            params.insert("v".to_string(), arr1(&[value]));
1546            Self {
1547                params,
1548                update_calls: 0,
1549            }
1550        }
1551    }
1552
1553    impl ValueNetwork<f64> for MockValue {
1554        fn evaluate_value(&self, observations: &Array2<f64>) -> Result<Array1<f64>> {
1555            Ok(Array1::from_elem(observations.nrows(), self.params["v"][0]))
1556        }
1557
1558        fn update_parameters(&mut self, deltas: &HashMap<String, Array1<f64>>) -> Result<()> {
1559            self.update_calls += 1;
1560            for (key, delta) in deltas {
1561                if let Some(p) = self.params.get_mut(key) {
1562                    if p.len() == delta.len() {
1563                        *p = &*p + delta;
1564                    }
1565                }
1566            }
1567            Ok(())
1568        }
1569
1570        fn get_parameters(&self) -> HashMap<String, Array1<f64>> {
1571            self.params.clone()
1572        }
1573
1574        fn value_gradient(
1575            &self,
1576            _observations: &Array2<f64>,
1577            residuals: &Array1<f64>,
1578        ) -> Result<HashMap<String, Array1<f64>>> {
1579            let mut map = HashMap::new();
1580            map.insert("v".to_string(), arr1(&[residuals.iter().sum::<f64>()]));
1581            Ok(map)
1582        }
1583    }
1584
1585    /// Build a tiny 4-step trajectory (2-dim observations, 1-dim actions).
1586    /// Old log-probs are all zero so the mock's offset directly sets log_ratio.
1587    fn make_trajectory() -> TrajectoryBatch<f64> {
1588        let observations = arr2(&[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]]);
1589        let actions = arr2(&[[0.0], [1.0], [0.0], [1.0]]);
1590        let log_probs = arr1(&[0.0, 0.0, 0.0, 0.0]);
1591        let rewards = arr1(&[1.0, 0.5, 0.25, 1.0]);
1592        let values = arr1(&[0.0, 0.0, 0.0, 0.0]);
1593        let dones = Array1::from_vec(vec![false, false, false, true]);
1594        TrajectoryBatch::new(observations, actions, log_probs, rewards, values, dones)
1595            .expect("valid trajectory")
1596    }
1597
1598    fn make_optimizer(
1599        log_prob_offset: f64,
1600        kl_coeff: f64,
1601        target_kl: f64,
1602    ) -> PolicyGradientOptimizer<f64, MockPolicy, MockValue> {
1603        let ppo_config = PPOConfig::<f64> {
1604            kl_coeff,
1605            target_kl,
1606            // Disable early stopping so every mini-batch contributes to epoch KL.
1607            early_stop_on_kl: false,
1608            ..PPOConfig::default()
1609        };
1610        let base_config = RLOptimizerConfig::<f64> {
1611            // One epoch; mini-batch covers the whole 4-step trajectory.
1612            n_epochs: 1,
1613            mini_batchsize: 4,
1614            ..RLOptimizerConfig::default()
1615        };
1616        let config = PolicyGradientConfig::<f64> {
1617            base_config,
1618            method: PolicyGradientMethod::PPOAdaptiveKL,
1619            ppo_config,
1620            ..PolicyGradientConfig::default()
1621        };
1622        PolicyGradientOptimizer::new(
1623            config,
1624            MockPolicy::new(log_prob_offset),
1625            Some(MockValue::new()),
1626        )
1627    }
1628
1629    #[test]
1630    fn test_adaptive_kl_runs_and_reports_finite_kl() {
1631        // Moderate KL via a small negative offset.
1632        let mut opt = make_optimizer(-0.01, 0.2, 0.01);
1633        let traj = make_trajectory();
1634        let metrics = opt.update(traj).expect("update should succeed");
1635
1636        let kl = metrics.kl_divergence.expect("kl_divergence must be set");
1637        assert!(kl.is_finite(), "KL must be finite, got {kl}");
1638        // Adaptive-KL does not clip, so clip_fraction is None.
1639        assert!(metrics.clip_fraction.is_none());
1640        // Losses must all be finite.
1641        assert!(metrics.policy_loss.is_finite());
1642        assert!(metrics.value_loss.is_finite());
1643        assert!(metrics.total_loss.is_finite());
1644    }
1645
1646    #[test]
1647    fn test_adaptive_kl_increases_beta_on_large_kl() {
1648        // offset = -1.0 ⇒ KL ≈ 1.0, far above 1.5 * target_kl (=0.015) ⇒ β doubles.
1649        let mut opt = make_optimizer(-1.0, 0.2, 0.01);
1650        let beta_before = opt.kl_coeff;
1651        let traj = make_trajectory();
1652        let _ = opt.update(traj).expect("update should succeed");
1653        let beta_after = opt.kl_coeff;
1654
1655        assert!(
1656            beta_after > beta_before,
1657            "β should increase on large KL: before={beta_before}, after={beta_after}"
1658        );
1659        assert!((beta_after - beta_before * 2.0).abs() < 1e-9);
1660    }
1661
1662    #[test]
1663    fn test_adaptive_kl_decreases_beta_on_small_kl() {
1664        // offset = 0.0 ⇒ KL = 0, far below target_kl / 1.5 ⇒ β halves.
1665        let mut opt = make_optimizer(0.0, 0.2, 0.01);
1666        let beta_before = opt.kl_coeff;
1667        let traj = make_trajectory();
1668        let _ = opt.update(traj).expect("update should succeed");
1669        let beta_after = opt.kl_coeff;
1670
1671        assert!(
1672            beta_after < beta_before,
1673            "β should decrease on small KL: before={beta_before}, after={beta_after}"
1674        );
1675        assert!((beta_after - beta_before / 2.0).abs() < 1e-9);
1676    }
1677
1678    #[test]
1679    fn test_adaptive_kl_beta_persists_across_updates() {
1680        // Repeated large-KL updates should keep growing β multiplicatively
1681        // until the clamp ceiling (1e4) is reached.
1682        let mut opt = make_optimizer(-1.0, 0.2, 0.01);
1683        let beta0 = opt.kl_coeff;
1684
1685        let _ = opt.update(make_trajectory()).expect("update 1");
1686        let beta1 = opt.kl_coeff;
1687        let _ = opt.update(make_trajectory()).expect("update 2");
1688        let beta2 = opt.kl_coeff;
1689
1690        assert!(beta1 > beta0);
1691        assert!(beta2 > beta1);
1692        // Two doublings from 0.2 (still well under the clamp ceiling).
1693        assert!((beta2 - beta0 * 4.0).abs() < 1e-9);
1694    }
1695
1696    // ----------------------------------------------------------------------
1697    // A2C / A3C / IMPALA tests
1698    // ----------------------------------------------------------------------
1699
1700    /// Build an optimizer for an arbitrary method with a configurable value
1701    /// baseline and behavior/learner log-prob offset.
1702    fn make_method_optimizer(
1703        method: PolicyGradientMethod,
1704        log_prob_offset: f64,
1705        value_baseline: f64,
1706    ) -> PolicyGradientOptimizer<f64, MockPolicy, MockValue> {
1707        let base_config = RLOptimizerConfig::<f64> {
1708            n_epochs: 1,
1709            mini_batchsize: 4,
1710            ..RLOptimizerConfig::default()
1711        };
1712        let config = PolicyGradientConfig::<f64> {
1713            base_config,
1714            method,
1715            ..PolicyGradientConfig::default()
1716        };
1717        PolicyGradientOptimizer::new(
1718            config,
1719            MockPolicy::new(log_prob_offset),
1720            Some(MockValue::with_value(value_baseline)),
1721        )
1722    }
1723
1724    #[test]
1725    fn test_actor_critic_runs_and_forwards_updates() {
1726        // A2C with a non-zero value baseline so the value loss is informative.
1727        let mut opt = make_method_optimizer(PolicyGradientMethod::ActorCritic, -0.2, 0.1);
1728        let traj = make_trajectory();
1729        let metrics = opt.update(traj).expect("A2C update should succeed");
1730
1731        // All reported losses must be finite.
1732        assert!(
1733            metrics.policy_loss.is_finite(),
1734            "policy loss must be finite"
1735        );
1736        assert!(metrics.value_loss.is_finite(), "value loss must be finite");
1737        assert!(
1738            metrics.entropy_loss.is_finite(),
1739            "entropy loss must be finite"
1740        );
1741        assert!(metrics.total_loss.is_finite(), "total loss must be finite");
1742        // A2C performs no clipping and is on-policy w.r.t. the data.
1743        assert!(metrics.clip_fraction.is_none());
1744        assert_eq!(metrics.kl_divergence, Some(0.0));
1745        // Value loss must be strictly positive: V=0.1 vs non-trivial returns.
1746        assert!(metrics.value_loss > 0.0);
1747
1748        // Gradients must actually have been forwarded to BOTH mock networks.
1749        assert!(
1750            opt.policy_network.update_calls > 0,
1751            "policy network must receive parameter updates"
1752        );
1753        let value_net = opt.value_network.as_ref().expect("value net present");
1754        assert!(
1755            value_net.update_calls > 0,
1756            "value network must receive parameter updates"
1757        );
1758        // And the parameters must have moved away from their zero init (the
1759        // value gradient is non-zero because the value loss is non-zero).
1760        let v = &value_net.get_parameters()["v"];
1761        assert!(v.iter().any(|&x| x != 0.0), "value params must change");
1762    }
1763
1764    #[test]
1765    fn test_a3c_dispatches_and_equals_a2c() {
1766        // A3C is asynchronous A2C; a single synchronous update must produce
1767        // results identical to ActorCritic given the same fresh state + data.
1768        let mut a2c = make_method_optimizer(PolicyGradientMethod::ActorCritic, -0.2, 0.1);
1769        let mut a3c = make_method_optimizer(PolicyGradientMethod::A3C, -0.2, 0.1);
1770
1771        let m_a2c = a2c.update(make_trajectory()).expect("A2C update");
1772        let m_a3c = a3c.update(make_trajectory()).expect("A3C update");
1773
1774        // Neither must error, and the per-update math is identical.
1775        assert_eq!(m_a2c.policy_loss, m_a3c.policy_loss);
1776        assert_eq!(m_a2c.value_loss, m_a3c.value_loss);
1777        assert_eq!(m_a2c.entropy_loss, m_a3c.entropy_loss);
1778        assert_eq!(m_a2c.total_loss, m_a3c.total_loss);
1779        assert_eq!(m_a2c.kl_divergence, m_a3c.kl_divergence);
1780        assert_eq!(m_a2c.clip_fraction, m_a3c.clip_fraction);
1781
1782        // The networks must have been updated identically too.
1783        assert_eq!(
1784            a2c.policy_network.get_parameters()["w"],
1785            a3c.policy_network.get_parameters()["w"]
1786        );
1787        // Note: this mock's log-probability is state-independent, so with
1788        // mean-zero (normalized) advantages its exact policy gradient is zero.
1789        // End-to-end learning is covered by the linear-policy convergence tests.
1790    }
1791
1792    #[test]
1793    fn test_impala_dispatches_and_forwards_updates() {
1794        // Off-policy IMPALA: behavior log-prob = 0, learner log-prob = ln(0.5)
1795        // ⇒ importance ratio 0.5, truncated to ρ = c = 0.5.
1796        let offset = 0.5_f64.ln();
1797        let mut opt = make_method_optimizer(PolicyGradientMethod::IMPALA, offset, 0.1);
1798        let metrics = opt
1799            .update(make_trajectory())
1800            .expect("IMPALA update should succeed");
1801
1802        assert!(metrics.policy_loss.is_finite());
1803        assert!(metrics.value_loss.is_finite());
1804        assert!(metrics.total_loss.is_finite());
1805        assert!(metrics.clip_fraction.is_none());
1806        // Off-policy diagnostic: mean truncated importance weight ≈ 0.5.
1807        let mean_rho = metrics
1808            .custom_metrics
1809            .get("mean_rho")
1810            .copied()
1811            .expect("mean_rho must be surfaced");
1812        assert!(
1813            (mean_rho - 0.5).abs() < 1e-9,
1814            "mean ρ should be 0.5, got {mean_rho}"
1815        );
1816
1817        // Both networks must receive parameter updates.
1818        assert!(opt.policy_network.update_calls > 0);
1819        assert!(opt.value_network.as_ref().expect("value net").update_calls > 0);
1820    }
1821
1822    #[test]
1823    fn test_impala_vtrace_target_matches_discounted_return() {
1824        // With learner == behavior policy (offset 0 ⇒ ratio 1 ⇒ ρ = c = 1) and a
1825        // zero value baseline, the V-trace targets collapse to the plain
1826        // discounted Monte-Carlo return. For a 2-step trajectory the value loss
1827        // (= mean(v_t²) since V≡0) is therefore exactly computable.
1828        let base_config = RLOptimizerConfig::<f64> {
1829            n_epochs: 1,
1830            mini_batchsize: 2,
1831            ..RLOptimizerConfig::default()
1832        };
1833        let config = PolicyGradientConfig::<f64> {
1834            base_config,
1835            method: PolicyGradientMethod::IMPALA,
1836            ..PolicyGradientConfig::default()
1837        };
1838        let mut opt = PolicyGradientOptimizer::new(
1839            config,
1840            MockPolicy::new(0.0), // learner log-prob == behavior log-prob
1841            Some(MockValue::with_value(0.0)),
1842        );
1843
1844        // 2-step trajectory: r = [1.0, 0.5], second step terminal, V ≡ 0.
1845        let observations = arr2(&[[0.1, 0.2], [0.3, 0.4]]);
1846        let actions = arr2(&[[0.0], [1.0]]);
1847        let log_probs = arr1(&[0.0, 0.0]);
1848        let rewards = arr1(&[1.0, 0.5]);
1849        let values = arr1(&[0.0, 0.0]);
1850        let dones = Array1::from_vec(vec![false, true]);
1851        let traj = TrajectoryBatch::new(observations, actions, log_probs, rewards, values, dones)
1852            .expect("valid trajectory");
1853
1854        let metrics = opt.update(traj).expect("IMPALA update should succeed");
1855
1856        // Discounted returns (γ = 0.99): v1 = 0.5, v0 = 1 + 0.99·0.5 = 1.495.
1857        let gamma = 0.99_f64;
1858        let v1 = 0.5;
1859        let v0 = 1.0 + gamma * v1;
1860        let expected_value_loss = (v0 * v0 + v1 * v1) / 2.0;
1861
1862        assert!(
1863            (metrics.value_loss - expected_value_loss).abs() < 1e-9,
1864            "V-trace value loss {} should equal discounted-return MSE {}",
1865            metrics.value_loss,
1866            expected_value_loss
1867        );
1868        // ρ = 1 everywhere here.
1869        let mean_rho = metrics
1870            .custom_metrics
1871            .get("mean_rho")
1872            .copied()
1873            .expect("mean_rho");
1874        assert!((mean_rho - 1.0).abs() < 1e-9);
1875    }
1876}