Skip to main content

optirs_core/reinforcement_learning/
mod.rs

1// Reinforcement Learning Optimizers
2//
3// This module provides specialized optimizers for reinforcement learning,
4// including policy gradient methods, actor-critic algorithms, and trust region methods.
5
6use crate::error::{OptimError, Result};
7use scirs2_core::ndarray::{Array1, Array2};
8use scirs2_core::numeric::Float;
9use std::collections::HashMap;
10use std::fmt::Debug;
11
12pub mod actor_critic;
13pub mod linear_models;
14pub mod natural_gradients;
15pub mod policy_gradient;
16pub mod trust_region;
17
18// Re-export key types
19pub use actor_critic::{ActorCriticConfig, ActorCriticMethod, ActorCriticOptimizer};
20pub use linear_models::{
21    LinearGaussianPolicy, LinearQFunction, LinearSoftmaxPolicy, LinearValueFunction,
22};
23pub use natural_gradients::{NaturalGradientConfig, NaturalPolicyGradient};
24pub use policy_gradient::{PolicyGradientConfig, PolicyGradientMethod, PolicyGradientOptimizer};
25pub use trust_region::{TrustRegionConfig, TrustRegionMethod, TrustRegionOptimizer};
26
27/// Reinforcement Learning optimization configuration
28#[derive(Debug, Clone)]
29pub struct RLOptimizerConfig<T: Float + Debug + Send + Sync + 'static> {
30    /// Policy learning rate
31    pub policy_lr: T,
32
33    /// Value function learning rate  
34    pub value_lr: T,
35
36    /// Discount factor (gamma)
37    pub discount_factor: T,
38
39    /// GAE lambda parameter
40    pub gae_lambda: T,
41
42    /// Clipping parameter for PPO
43    pub clip_epsilon: T,
44
45    /// Entropy regularization coefficient
46    pub entropy_coeff: T,
47
48    /// Value function loss coefficient
49    pub value_loss_coeff: T,
50
51    /// Maximum gradient norm for clipping
52    pub max_grad_norm: T,
53
54    /// Number of optimization epochs per update
55    pub n_epochs: usize,
56
57    /// Mini-batch size for optimization
58    pub mini_batchsize: usize,
59
60    /// Trust region methods configuration
61    pub trust_region_config: Option<TrustRegionConfig<T>>,
62
63    /// Enable natural policy gradients
64    pub use_natural_gradients: bool,
65
66    /// Fisher information matrix approximation method
67    pub fisher_approximation: FisherApproximationMethod,
68}
69
70/// Methods for approximating the Fisher Information Matrix
71#[derive(Debug, Clone, Copy)]
72pub enum FisherApproximationMethod {
73    /// Empirical Fisher Information Matrix
74    Empirical,
75
76    /// Kronecker-factored approximation
77    KroneckerFactored,
78
79    /// Diagonal approximation
80    Diagonal,
81
82    /// Block-diagonal approximation
83    BlockDiagonal,
84
85    /// Low-rank approximation
86    LowRank,
87}
88
89impl<T: Float + Debug + Send + Sync + 'static> Default for RLOptimizerConfig<T> {
90    fn default() -> Self {
91        Self {
92            policy_lr: T::from(3e-4).unwrap_or_else(|| T::zero()),
93            value_lr: T::from(1e-3).unwrap_or_else(|| T::zero()),
94            discount_factor: T::from(0.99).unwrap_or_else(|| T::zero()),
95            gae_lambda: T::from(0.95).unwrap_or_else(|| T::zero()),
96            clip_epsilon: T::from(0.2).unwrap_or_else(|| T::zero()),
97            entropy_coeff: T::from(0.01).unwrap_or_else(|| T::zero()),
98            value_loss_coeff: T::from(0.5).unwrap_or_else(|| T::zero()),
99            max_grad_norm: T::from(0.5).unwrap_or_else(|| T::zero()),
100            n_epochs: 4,
101            mini_batchsize: 64,
102            trust_region_config: None,
103            use_natural_gradients: false,
104            fisher_approximation: FisherApproximationMethod::Diagonal,
105        }
106    }
107}
108
109/// Trajectory data for RL optimization
110#[derive(Debug, Clone)]
111pub struct TrajectoryBatch<T: Float + Debug + Send + Sync + 'static> {
112    /// Observations
113    pub observations: Array2<T>,
114
115    /// Actions taken
116    pub actions: Array2<T>,
117
118    /// Log probabilities of actions
119    pub log_probs: Array1<T>,
120
121    /// Rewards received
122    pub rewards: Array1<T>,
123
124    /// Value function estimates
125    pub values: Array1<T>,
126
127    /// Done flags (episode termination)
128    pub dones: Array1<bool>,
129
130    /// Advantage estimates
131    pub advantages: Array1<T>,
132
133    /// Target returns
134    pub returns: Array1<T>,
135
136    /// Observation reached *after* the final transition of the batch (`s_T`).
137    ///
138    /// GAE and V-trace both need the value of the state that follows the last
139    /// stored transition in order to bootstrap. That state is **not** part of the
140    /// batch — `observations.row(len - 1)` is `s_{T-1}`, the state the last action
141    /// was taken *from*. Bootstrapping on `s_{T-1}` is an off-by-one error that
142    /// biases every advantage in the batch, so the successor state is carried
143    /// explicitly here.
144    ///
145    /// `None` means "no successor available" (e.g. the batch ends on a terminal
146    /// transition, or the caller did not record it); consumers then bootstrap with
147    /// zero. When the final transition is terminal the bootstrap is masked out by
148    /// `dones` regardless of this field.
149    pub final_observation: Option<Array1<T>>,
150}
151
152impl<T: Float + Debug + Send + Sync + 'static + scirs2_core::numeric::FromPrimitive>
153    TrajectoryBatch<T>
154{
155    /// Create a new trajectory batch
156    pub fn new(
157        observations: Array2<T>,
158        actions: Array2<T>,
159        log_probs: Array1<T>,
160        rewards: Array1<T>,
161        values: Array1<T>,
162        dones: Array1<bool>,
163    ) -> Result<Self> {
164        let batch_size = observations.nrows();
165
166        // Validate dimensions
167        if actions.nrows() != batch_size
168            || log_probs.len() != batch_size
169            || rewards.len() != batch_size
170            || values.len() != batch_size
171            || dones.len() != batch_size
172        {
173            return Err(OptimError::InvalidConfig(
174                "Inconsistent batch dimensions".to_string(),
175            ));
176        }
177
178        // Compute advantages and returns (will be updated by compute_advantages)
179        let advantages = Array1::zeros(batch_size);
180        let returns = Array1::zeros(batch_size);
181
182        Ok(Self {
183            observations,
184            actions,
185            log_probs,
186            rewards,
187            values,
188            dones,
189            advantages,
190            returns,
191            final_observation: None,
192        })
193    }
194
195    /// Attach the successor observation `s_T` used to bootstrap the final step.
196    ///
197    /// See [`TrajectoryBatch::final_observation`]. Returns an error if the
198    /// dimensionality does not match the batch's observation dimension.
199    pub fn with_final_observation(mut self, final_observation: Array1<T>) -> Result<Self> {
200        let expected = self.observations.ncols();
201        if final_observation.len() != expected {
202            return Err(OptimError::DimensionMismatch(format!(
203                "final observation length ({}) does not match observation dimension ({})",
204                final_observation.len(),
205                expected
206            )));
207        }
208        self.final_observation = Some(final_observation);
209        Ok(self)
210    }
211
212    /// Compute Generalized Advantage Estimation (GAE) **without** normalizing.
213    ///
214    /// ```text
215    /// δ_t = r_t + γ·(1 − done_t)·V(s_{t+1}) − V(s_t)
216    /// A_t = δ_t + γ·λ·(1 − done_t)·A_{t+1}
217    /// R_t = A_t + V(s_t)
218    /// ```
219    ///
220    /// `done_t` is read from `self.dones[t]` for **every** `t`, including the last
221    /// one: a batch whose final transition terminates the episode must not
222    /// bootstrap. `nextvalue` is `V(s_T)`, the value of the successor of the final
223    /// transition (see [`TrajectoryBatch::final_observation`]); it is ignored when
224    /// the final transition is terminal.
225    pub fn compute_gae(&mut self, gamma: T, lambda: T, nextvalue: T) -> Result<()> {
226        let batch_size = self.rewards.len();
227        if batch_size == 0 {
228            return Ok(());
229        }
230        let mut gae = T::zero();
231
232        for t in (0..batch_size).rev() {
233            // The terminal flag of step `t` itself — never a hardcoded `false`.
234            let nonterminal = if self.dones[t] { T::zero() } else { T::one() };
235
236            let next_val = if t == batch_size - 1 {
237                nextvalue
238            } else {
239                self.values[t + 1]
240            };
241
242            let delta = self.rewards[t] + gamma * next_val * nonterminal - self.values[t];
243            gae = delta + gamma * lambda * nonterminal * gae;
244
245            self.advantages[t] = gae;
246            self.returns[t] = gae + self.values[t];
247        }
248
249        Ok(())
250    }
251
252    /// Compute GAE advantages/returns and normalize the advantages to zero mean
253    /// and unit variance (the usual policy-gradient variance reduction).
254    ///
255    /// Normalization is skipped for batches of fewer than two samples (where the
256    /// sample standard deviation is zero and normalizing would annihilate the
257    /// signal) and whenever the spread is numerically negligible.
258    pub fn compute_advantages(&mut self, gamma: T, lambda: T, nextvalue: T) -> Result<()> {
259        self.compute_gae(gamma, lambda, nextvalue)?;
260
261        if self.advantages.len() < 2 {
262            return Ok(());
263        }
264
265        let mean = self.advantages.mean().unwrap_or(T::zero());
266        let std = self
267            .advantages
268            .mapv(|x| (x - mean) * (x - mean))
269            .mean()
270            .unwrap_or(T::one())
271            .sqrt();
272
273        if std > T::from(1e-8).unwrap_or_else(|| T::zero()) {
274            self.advantages.mapv_inplace(|x| (x - mean) / std);
275        }
276
277        Ok(())
278    }
279
280    /// Fill `returns` with plain discounted Monte-Carlo returns
281    /// `G_t = r_t + γ·(1 − done_t)·G_{t+1}`, bootstrapping the final step with
282    /// `nextvalue` when the final transition is non-terminal.
283    ///
284    /// Used by baseline-free REINFORCE, where the advantage *is* the return.
285    /// `advantages` is set to `G_t − V(s_t)` so downstream code that reads
286    /// advantages stays meaningful when a value baseline happens to be present.
287    pub fn compute_discounted_returns(&mut self, gamma: T, nextvalue: T) -> Result<()> {
288        let batch_size = self.rewards.len();
289        if batch_size == 0 {
290            return Ok(());
291        }
292
293        let mut running = nextvalue;
294        for t in (0..batch_size).rev() {
295            let nonterminal = if self.dones[t] { T::zero() } else { T::one() };
296            running = self.rewards[t] + gamma * nonterminal * running;
297            self.returns[t] = running;
298            self.advantages[t] = running - self.values[t];
299        }
300
301        Ok(())
302    }
303
304    /// Get mini-batches for optimization
305    pub fn get_mini_batches(&self, mini_batchsize: usize) -> Vec<TrajectoryBatch<T>> {
306        let batch_size = self.observations.nrows();
307        let n_mini_batches = batch_size.div_ceil(mini_batchsize);
308
309        let mut mini_batches = Vec::new();
310
311        for i in 0..n_mini_batches {
312            let start = i * mini_batchsize;
313            let end = ((i + 1) * mini_batchsize).min(batch_size);
314
315            if start >= end {
316                break;
317            }
318
319            let obs = self.observations.slice(s![start..end, ..]).to_owned();
320            let acts = self.actions.slice(s![start..end, ..]).to_owned();
321            let log_probs = self.log_probs.slice(s![start..end]).to_owned();
322            let rewards = self.rewards.slice(s![start..end]).to_owned();
323            let values = self.values.slice(s![start..end]).to_owned();
324            let dones = self.dones.slice(s![start..end]).to_owned().to_vec();
325            let advantages = self.advantages.slice(s![start..end]).to_owned();
326            let returns = self.returns.slice(s![start..end]).to_owned();
327
328            // Convert Vec<bool> back to Array1<bool>
329            let dones_array = Array1::from_vec(dones);
330
331            // The successor of this slice's last transition is the first
332            // observation of the next slice, or the whole batch's successor for
333            // the final slice.
334            let final_observation = if end < batch_size {
335                Some(self.observations.row(end).to_owned())
336            } else {
337                self.final_observation.clone()
338            };
339
340            let mini_batch = TrajectoryBatch {
341                observations: obs,
342                actions: acts,
343                log_probs,
344                rewards,
345                values,
346                dones: dones_array,
347                advantages,
348                returns,
349                final_observation,
350            };
351
352            mini_batches.push(mini_batch);
353        }
354
355        mini_batches
356    }
357}
358
359/// A Kronecker-factored block of the Fisher information matrix.
360///
361/// K-FAC approximates the Fisher block of a linear layer `W ∈ R^{n_out × n_in}` as
362/// `F ≈ G ⊗ A` with `A = E[φ φᵀ]` (input/activation covariance) and
363/// `G = E[δ δᵀ]` (output pre-activation gradient covariance). This struct carries
364/// the *per-sample* factors so the covariances can be formed by the consumer:
365/// row `i` of `inputs` is `φ_i`, row `i` of `outputs` is `δ_i`.
366///
367/// Contract: the per-sample score for the named parameter, reshaped **row-major**
368/// to `(n_out, n_in)`, must equal `δ_i φ_iᵀ`.
369#[derive(Debug, Clone)]
370pub struct KroneckerBlock<T: Float + Debug + Send + Sync + 'static> {
371    /// Name of the parameter this block factorizes (a key of `get_parameters`).
372    pub name: String,
373
374    /// Per-sample layer inputs, shape `(n_samples, n_in)`.
375    pub inputs: Array2<T>,
376
377    /// Per-sample pre-activation gradients, shape `(n_samples, n_out)`.
378    pub outputs: Array2<T>,
379}
380
381/// Total number of scalars across a named-parameter map.
382pub fn parameter_count<T: Float + Debug + Send + Sync + 'static>(
383    params: &HashMap<String, Array1<T>>,
384) -> usize {
385    params.values().map(|p| p.len()).sum()
386}
387
388/// Parameter keys in deterministic (sorted) order — the canonical flat layout.
389pub fn parameter_keys<T: Float + Debug + Send + Sync + 'static>(
390    params: &HashMap<String, Array1<T>>,
391) -> Vec<String> {
392    let mut keys: Vec<String> = params.keys().cloned().collect();
393    keys.sort();
394    keys
395}
396
397/// Flatten a named-parameter map into a single vector using the canonical
398/// (sorted-key, contiguous) layout shared by every flat/named conversion here.
399pub fn flatten_named<T: Float + Debug + Send + Sync + 'static>(
400    params: &HashMap<String, Array1<T>>,
401) -> Array1<T> {
402    let mut flat = Array1::zeros(parameter_count(params));
403    let mut offset = 0usize;
404    for key in parameter_keys(params) {
405        let value = &params[&key];
406        for (i, &v) in value.iter().enumerate() {
407            flat[offset + i] = v;
408        }
409        offset += value.len();
410    }
411    flat
412}
413
414/// Split a flat vector back into a named-parameter map matching `template`'s
415/// keys and lengths, using the canonical sorted-key layout.
416///
417/// Returns [`OptimError::DimensionMismatch`] when the flat length does not equal
418/// the template's total parameter count.
419pub fn unflatten_named<T: Float + Debug + Send + Sync + 'static>(
420    template: &HashMap<String, Array1<T>>,
421    flat: &Array1<T>,
422) -> Result<HashMap<String, Array1<T>>> {
423    let total = parameter_count(template);
424    if total != flat.len() {
425        return Err(OptimError::DimensionMismatch(format!(
426            "Flat vector length ({}) does not match total parameter count ({})",
427            flat.len(),
428            total
429        )));
430    }
431
432    let keys = parameter_keys(template);
433    let mut out: HashMap<String, Array1<T>> = HashMap::with_capacity(keys.len());
434    let mut offset = 0usize;
435    for key in keys {
436        let len = template[&key].len();
437        let mut chunk = Array1::zeros(len);
438        for i in 0..len {
439            chunk[i] = flat[offset + i];
440        }
441        out.insert(key, chunk);
442        offset += len;
443    }
444    Ok(out)
445}
446
447/// Global-norm clipping of a named-gradient map.
448///
449/// Returns the clipped gradients together with the **pre-clipping** global norm
450/// (what the metrics should report). A non-positive `max_norm` disables clipping.
451pub fn clip_named_gradients<T: Float + Debug + Send + Sync + 'static>(
452    gradients: &HashMap<String, Array1<T>>,
453    max_norm: T,
454) -> (HashMap<String, Array1<T>>, T) {
455    let mut total = T::zero();
456    for grad in gradients.values() {
457        for &g in grad.iter() {
458            total = total + g * g;
459        }
460    }
461    let norm = total.sqrt();
462
463    let factor = if max_norm > T::zero() && norm > max_norm && norm > T::zero() {
464        max_norm / norm
465    } else {
466        T::one()
467    };
468
469    let clipped = gradients
470        .iter()
471        .map(|(name, grad)| (name.clone(), grad.mapv(|g| g * factor)))
472        .collect();
473
474    (clipped, norm)
475}
476
477/// Multiply every entry of a named-gradient map by `factor`.
478pub fn scale_named_gradients<T: Float + Debug + Send + Sync + 'static>(
479    gradients: &HashMap<String, Array1<T>>,
480    factor: T,
481) -> HashMap<String, Array1<T>> {
482    gradients
483        .iter()
484        .map(|(name, grad)| (name.clone(), grad.mapv(|g| g * factor)))
485        .collect()
486}
487
488/// Accumulate `addend` into `base`, matching entries by name.
489///
490/// Returns [`OptimError::DimensionMismatch`] when a shared key has mismatched
491/// lengths; keys present only in `addend` are inserted as-is.
492pub fn add_named_gradients<T: Float + Debug + Send + Sync + 'static>(
493    base: &mut HashMap<String, Array1<T>>,
494    addend: HashMap<String, Array1<T>>,
495) -> Result<()> {
496    for (name, grad) in addend {
497        match base.get_mut(&name) {
498            Some(target) => {
499                if target.len() != grad.len() {
500                    return Err(OptimError::DimensionMismatch(format!(
501                        "gradient '{name}' has length {} in one term and {} in the other",
502                        target.len(),
503                        grad.len()
504                    )));
505                }
506                for i in 0..target.len() {
507                    target[i] = target[i] + grad[i];
508                }
509            }
510            None => {
511                base.insert(name, grad);
512            }
513        }
514    }
515    Ok(())
516}
517
518/// Policy network interface for RL optimizers.
519///
520/// # Parameter update contract
521///
522/// [`PolicyNetwork::update_parameters`] receives a **parameter delta**, not a raw
523/// gradient: the optimizer has already applied the learning rate, the gradient
524/// clipping and the sign (descent on the loss). Implementations must therefore
525/// *add* the supplied arrays to their parameters. Every optimizer in this module
526/// (policy gradient, trust region, natural gradient, target-network soft updates)
527/// relies on this additive semantics.
528///
529/// # Gradient oracle
530///
531/// The `*_gradient` methods form the differentiable path used by every learning
532/// rule here. They have no meaningful default, so the default bodies return
533/// [`OptimError::UnsupportedOperation`] — a policy that cannot differentiate
534/// itself must fail loudly rather than be "trained" with a fabricated gradient.
535/// [`linear_models`] provides ready-made analytic implementations.
536pub trait PolicyNetwork<T: Float + Debug + Send + Sync + 'static> {
537    /// Evaluate actions for given observations
538    fn evaluate_actions(
539        &self,
540        observations: &Array2<T>,
541        actions: &Array2<T>,
542    ) -> Result<PolicyEvaluation<T>>;
543
544    /// Get action distribution for given observations
545    fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>>;
546
547    /// Add a parameter delta to the policy parameters (see the trait docs).
548    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()>;
549
550    /// Get current policy parameters
551    fn get_parameters(&self) -> HashMap<String, Array1<T>>;
552
553    /// Gradient of a coefficient-weighted sum of log-probabilities:
554    /// `∂/∂θ Σᵢ cᵢ · log π(aᵢ | sᵢ)`.
555    ///
556    /// Every surrogate loss implemented in this module — REINFORCE, A2C/A3C,
557    /// PPO-clip, PPO adaptive-KL, V-trace/IMPALA — has a policy gradient of
558    /// exactly this shape with `cᵢ = ∂L/∂ log π(aᵢ|sᵢ)`, so this single oracle is
559    /// enough to train all of them end to end.
560    ///
561    /// The returned map must have the same keys and lengths as
562    /// [`Self::get_parameters`].
563    fn log_prob_gradient(
564        &self,
565        observations: &Array2<T>,
566        actions: &Array2<T>,
567        coefficients: &Array1<T>,
568    ) -> Result<HashMap<String, Array1<T>>> {
569        let _ = (observations, actions, coefficients);
570        Err(OptimError::UnsupportedOperation(
571            "PolicyNetwork::log_prob_gradient is not implemented for this policy; \
572             policy-gradient updates require an analytic (or autodiff) score function"
573                .to_string(),
574        ))
575    }
576
577    /// Gradient of the batch-mean entropy `∂/∂θ (1/N) Σᵢ H[π(·|sᵢ)]`.
578    ///
579    /// Only consulted when the entropy coefficient is non-zero.
580    fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
581        let _ = observations;
582        Err(OptimError::UnsupportedOperation(
583            "PolicyNetwork::entropy_gradient is not implemented for this policy; \
584             set entropy_coeff = 0 or provide an analytic entropy gradient"
585                .to_string(),
586        ))
587    }
588
589    /// Gradient of a weighted sum of the distribution mean:
590    /// `∂/∂θ Σᵢ Σⱼ w[i,j] · μⱼ(sᵢ)`.
591    ///
592    /// This is the chain-rule hook required by the *deterministic* policy gradient
593    /// (DDPG/TD3) and by the reparameterized SAC actor update, where the loss
594    /// depends on the parameters through the sampled action rather than through
595    /// the log-probability.
596    fn mean_action_gradient(
597        &self,
598        observations: &Array2<T>,
599        weights: &Array2<T>,
600    ) -> Result<HashMap<String, Array1<T>>> {
601        let _ = (observations, weights);
602        Err(OptimError::UnsupportedOperation(
603            "PolicyNetwork::mean_action_gradient is not implemented for this policy; \
604             deterministic-policy-gradient updates (DDPG/TD3/SAC actor) require it"
605                .to_string(),
606        ))
607    }
608
609    /// Per-sample score vectors `g_i = ∇_θ log π(aᵢ|sᵢ)`, one per **row**, flattened
610    /// with [`flatten_named`]'s canonical layout.
611    ///
612    /// Used to build empirical / block-diagonal Fisher estimates. The default
613    /// implementation derives them from [`Self::log_prob_gradient`] one sample at a
614    /// time, which is correct but costs `N` oracle calls; policies that can produce
615    /// them in one pass should override it.
616    fn score_matrix(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
617        let n = observations.nrows();
618        let dim = parameter_count(&self.get_parameters());
619        let mut scores = Array2::zeros((n, dim));
620
621        let one = Array1::from_elem(1, T::one());
622        for i in 0..n {
623            let obs_i = observations.slice(s![i..i + 1, ..]).to_owned();
624            let act_i = actions.slice(s![i..i + 1, ..]).to_owned();
625            let grad = self.log_prob_gradient(&obs_i, &act_i, &one)?;
626            let flat = flatten_named(&grad);
627            if flat.len() != dim {
628                return Err(OptimError::DimensionMismatch(format!(
629                    "score vector length ({}) does not match parameter count ({})",
630                    flat.len(),
631                    dim
632                )));
633            }
634            for j in 0..dim {
635                scores[[i, j]] = flat[j];
636            }
637        }
638
639        Ok(scores)
640    }
641
642    /// Per-sample Kronecker factors of the Fisher information matrix.
643    ///
644    /// See [`KroneckerBlock`] for the exact contract. Returning
645    /// [`OptimError::UnsupportedOperation`] (the default) makes K-FAC estimation
646    /// fail loudly instead of silently degrading to an identity Fisher.
647    fn kronecker_factors(
648        &self,
649        observations: &Array2<T>,
650        actions: &Array2<T>,
651    ) -> Result<Vec<KroneckerBlock<T>>> {
652        let _ = (observations, actions);
653        Err(OptimError::UnsupportedOperation(
654            "PolicyNetwork::kronecker_factors is not implemented for this policy; \
655             Kronecker-factored Fisher estimation requires per-layer factors"
656                .to_string(),
657        ))
658    }
659}
660
661/// Blanket forwarding so a `&mut P` can stand in for an owned policy.
662///
663/// This lets an optimizer that already owns a policy hand a *borrow* of it to
664/// another optimizer (e.g. [`policy_gradient::PolicyGradientOptimizer`] routing
665/// its TRPO update through [`trust_region::TrustRegionOptimizer`]) without
666/// transferring ownership. Every method — including the gradient oracle — is
667/// forwarded, so the borrow behaves exactly like the underlying policy.
668impl<T: Float + Debug + Send + Sync + 'static, P: PolicyNetwork<T> + ?Sized> PolicyNetwork<T>
669    for &mut P
670{
671    fn evaluate_actions(
672        &self,
673        observations: &Array2<T>,
674        actions: &Array2<T>,
675    ) -> Result<PolicyEvaluation<T>> {
676        (**self).evaluate_actions(observations, actions)
677    }
678
679    fn get_action_distribution(&self, observations: &Array2<T>) -> Result<ActionDistribution<T>> {
680        (**self).get_action_distribution(observations)
681    }
682
683    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()> {
684        (**self).update_parameters(deltas)
685    }
686
687    fn get_parameters(&self) -> HashMap<String, Array1<T>> {
688        (**self).get_parameters()
689    }
690
691    fn log_prob_gradient(
692        &self,
693        observations: &Array2<T>,
694        actions: &Array2<T>,
695        coefficients: &Array1<T>,
696    ) -> Result<HashMap<String, Array1<T>>> {
697        (**self).log_prob_gradient(observations, actions, coefficients)
698    }
699
700    fn entropy_gradient(&self, observations: &Array2<T>) -> Result<HashMap<String, Array1<T>>> {
701        (**self).entropy_gradient(observations)
702    }
703
704    fn mean_action_gradient(
705        &self,
706        observations: &Array2<T>,
707        weights: &Array2<T>,
708    ) -> Result<HashMap<String, Array1<T>>> {
709        (**self).mean_action_gradient(observations, weights)
710    }
711
712    fn score_matrix(&self, observations: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
713        (**self).score_matrix(observations, actions)
714    }
715
716    fn kronecker_factors(
717        &self,
718        observations: &Array2<T>,
719        actions: &Array2<T>,
720    ) -> Result<Vec<KroneckerBlock<T>>> {
721        (**self).kronecker_factors(observations, actions)
722    }
723}
724
725/// Value network interface for RL optimizers.
726///
727/// [`ValueNetwork::update_parameters`] follows the same additive **delta**
728/// contract as [`PolicyNetwork::update_parameters`].
729pub trait ValueNetwork<T: Float + Debug + Send + Sync + 'static> {
730    /// Evaluate value function for given observations
731    fn evaluate_value(&self, observations: &Array2<T>) -> Result<Array1<T>>;
732
733    /// Add a parameter delta to the value-function parameters.
734    fn update_parameters(&mut self, deltas: &HashMap<String, Array1<T>>) -> Result<()>;
735
736    /// Get current value function parameters
737    fn get_parameters(&self) -> HashMap<String, Array1<T>>;
738
739    /// Gradient of a residual-weighted sum of value predictions:
740    /// `∂/∂θ Σᵢ rᵢ · V(sᵢ)`.
741    ///
742    /// Callers pass `rᵢ = ∂L/∂V(sᵢ)`; for the mean-squared value loss
743    /// `L = (1/N) Σ (V(sᵢ) − yᵢ)²` that is `rᵢ = 2(V(sᵢ) − yᵢ)/N`.
744    fn value_gradient(
745        &self,
746        observations: &Array2<T>,
747        residuals: &Array1<T>,
748    ) -> Result<HashMap<String, Array1<T>>> {
749        let _ = (observations, residuals);
750        Err(OptimError::UnsupportedOperation(
751            "ValueNetwork::value_gradient is not implemented for this network; \
752             value-function updates require an analytic (or autodiff) gradient"
753                .to_string(),
754        ))
755    }
756}
757
758/// Action-value (Q) network interface.
759///
760/// The off-policy actor-critic methods (SAC, TD3, DDPG) are built on `Q(s, a)`,
761/// not on a state value `V(s)`: without the action argument the deterministic
762/// policy gradient `∇_a Q(s, a)` does not exist and the critic cannot distinguish
763/// the actions it is supposed to rank.
764///
765/// A pure Q network has no intrinsic state value, so it is free to return
766/// [`OptimError::UnsupportedOperation`] from
767/// [`ValueNetwork::evaluate_value`] — see [`linear_models::LinearQFunction`].
768pub trait QNetwork<T: Float + Debug + Send + Sync + 'static>: ValueNetwork<T> {
769    /// Evaluate `Q(s, a)` for a batch of state-action pairs.
770    fn evaluate_q(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array1<T>>;
771
772    /// Gradient of a residual-weighted sum of Q predictions:
773    /// `∂/∂θ Σᵢ rᵢ · Q(sᵢ, aᵢ)`.
774    fn q_gradient(
775        &self,
776        states: &Array2<T>,
777        actions: &Array2<T>,
778        residuals: &Array1<T>,
779    ) -> Result<HashMap<String, Array1<T>>> {
780        let _ = (states, actions, residuals);
781        Err(OptimError::UnsupportedOperation(
782            "QNetwork::q_gradient is not implemented for this critic".to_string(),
783        ))
784    }
785
786    /// `∇_a Q(s, a)` for each row, shape `(n_samples, action_dim)`.
787    ///
788    /// This is the term the deterministic policy gradient chains with
789    /// [`PolicyNetwork::mean_action_gradient`].
790    fn action_gradient(&self, states: &Array2<T>, actions: &Array2<T>) -> Result<Array2<T>> {
791        let _ = (states, actions);
792        Err(OptimError::UnsupportedOperation(
793            "QNetwork::action_gradient is not implemented for this critic; \
794             the deterministic policy gradient requires ∇_a Q(s, a)"
795                .to_string(),
796        ))
797    }
798}
799
800/// Policy evaluation results
801#[derive(Debug, Clone)]
802pub struct PolicyEvaluation<T: Float + Debug + Send + Sync + 'static> {
803    /// Log probabilities of actions
804    pub log_probs: Array1<T>,
805
806    /// Entropy of action distribution
807    pub entropy: Array1<T>,
808
809    /// Additional metrics
810    pub metrics: HashMap<String, T>,
811}
812
813/// Action distribution representation
814#[derive(Debug, Clone)]
815pub struct ActionDistribution<T: Float + Debug + Send + Sync + 'static> {
816    /// Mean of the distribution (for continuous actions)
817    pub mean: Option<Array2<T>>,
818
819    /// Standard deviation (for continuous actions)
820    pub std: Option<Array2<T>>,
821
822    /// Logits (for discrete actions)
823    pub logits: Option<Array2<T>>,
824
825    /// Distribution type
826    pub distribution_type: DistributionType,
827}
828
829/// Types of action distributions
830#[derive(Debug, Clone, Copy)]
831pub enum DistributionType {
832    /// Continuous Gaussian distribution
833    Gaussian,
834
835    /// Discrete categorical distribution
836    Categorical,
837
838    /// Beta distribution (for bounded continuous actions)
839    Beta,
840
841    /// Mixed discrete-continuous
842    Mixed,
843}
844
845/// Learning rate scheduling for RL optimizers
846#[derive(Debug, Clone)]
847pub struct RLScheduler<T: Float + Debug + Send + Sync + 'static> {
848    /// Initial learning rate
849    pub initiallr: T,
850
851    /// Current learning rate
852    pub current_lr: T,
853
854    /// Decay factor
855    pub decay_factor: T,
856
857    /// Decay schedule
858    pub schedule: ScheduleType,
859
860    /// Number of updates so far
861    pub update_count: usize,
862
863    /// Schedule parameters
864    pub schedule_params: HashMap<String, T>,
865}
866
867/// Learning rate schedule types
868#[derive(Debug, Clone, Copy)]
869pub enum ScheduleType {
870    /// Constant learning rate
871    Constant,
872
873    /// Linear decay
874    Linear,
875
876    /// Exponential decay
877    Exponential,
878
879    /// Cosine annealing
880    Cosine,
881
882    /// Step decay
883    Step,
884
885    /// Adaptive based on performance
886    Adaptive,
887}
888
889impl<T: Float + Debug + Send + Sync + 'static> RLScheduler<T> {
890    /// Create a new learning rate scheduler
891    pub fn new(initiallr: T, schedule: ScheduleType) -> Self {
892        Self {
893            initiallr,
894            current_lr: initiallr,
895            decay_factor: T::from(0.99).unwrap_or_else(|| T::zero()),
896            schedule,
897            update_count: 0,
898            schedule_params: HashMap::new(),
899        }
900    }
901
902    /// Update learning rate based on schedule
903    pub fn step(&mut self) -> T {
904        self.update_count += 1;
905
906        match self.schedule {
907            ScheduleType::Constant => {
908                // No change
909            }
910            ScheduleType::Linear => {
911                let decay_steps = self
912                    .schedule_params
913                    .get("decay_steps")
914                    .copied()
915                    .unwrap_or(T::from(10000).unwrap_or_else(|| T::zero()));
916                let progress =
917                    T::from(self.update_count).unwrap_or_else(|| T::zero()) / decay_steps;
918                self.current_lr = self.initiallr * (T::one() - progress).max(T::zero());
919            }
920            ScheduleType::Exponential => {
921                self.current_lr = self.current_lr * self.decay_factor;
922            }
923            ScheduleType::Step => {
924                let step_size = self
925                    .schedule_params
926                    .get("step_size")
927                    .copied()
928                    .unwrap_or(T::from(1000).unwrap_or_else(|| T::zero()));
929                if T::from(self.update_count).unwrap_or_else(|| T::zero()) % step_size == T::zero()
930                {
931                    self.current_lr = self.current_lr * self.decay_factor;
932                }
933            }
934            ScheduleType::Cosine => {
935                let max_steps = self
936                    .schedule_params
937                    .get("max_steps")
938                    .copied()
939                    .unwrap_or(T::from(10000).unwrap_or_else(|| T::zero()));
940                let progress = T::from(self.update_count).unwrap_or_else(|| T::zero()) / max_steps;
941                let pi = T::from(std::f64::consts::PI).unwrap_or_else(|| T::zero());
942                self.current_lr = self.initiallr * (T::one() + (pi * progress).cos())
943                    / T::from(2).unwrap_or_else(|| T::zero());
944            }
945            ScheduleType::Adaptive => {
946                // Adaptive scheduling based on performance metrics
947                // Implementation depends on specific performance indicators
948            }
949        }
950
951        self.current_lr
952    }
953
954    /// Get current learning rate
955    pub fn get_lr(&self) -> T {
956        self.current_lr
957    }
958
959    /// Set schedule parameter
960    pub fn set_param(&mut self, key: &str, value: T) {
961        self.schedule_params.insert(key.to_string(), value);
962    }
963}
964
965/// RL optimization metrics
966#[derive(Debug, Clone)]
967pub struct RLOptimizationMetrics<T: Float + Debug + Send + Sync + 'static> {
968    /// Policy loss
969    pub policy_loss: T,
970
971    /// Value function loss
972    pub value_loss: T,
973
974    /// Entropy loss
975    pub entropy_loss: T,
976
977    /// Total loss
978    pub total_loss: T,
979
980    /// KL divergence (for trust region methods)
981    pub kl_divergence: Option<T>,
982
983    /// Explained variance
984    pub explained_variance: T,
985
986    /// Clip fraction (for PPO)
987    pub clip_fraction: Option<T>,
988
989    /// Learning rates
990    pub policy_lr: T,
991    pub value_lr: T,
992
993    /// Gradient norms
994    pub policy_grad_norm: T,
995    pub value_grad_norm: T,
996
997    /// Additional metrics
998    pub custom_metrics: HashMap<String, T>,
999}
1000
1001impl<T: Float + Debug + Send + Sync + 'static> Default for RLOptimizationMetrics<T> {
1002    fn default() -> Self {
1003        Self {
1004            policy_loss: T::zero(),
1005            value_loss: T::zero(),
1006            entropy_loss: T::zero(),
1007            total_loss: T::zero(),
1008            kl_divergence: None,
1009            explained_variance: T::zero(),
1010            clip_fraction: None,
1011            policy_lr: T::from(3e-4).unwrap_or_else(|| T::zero()),
1012            value_lr: T::from(1e-3).unwrap_or_else(|| T::zero()),
1013            policy_grad_norm: T::zero(),
1014            value_grad_norm: T::zero(),
1015            custom_metrics: HashMap::new(),
1016        }
1017    }
1018}
1019
1020// Import slice syntax
1021use scirs2_core::ndarray::s;
1022// use statrs::statistics::Statistics; // statrs not available