Skip to main content

optirs_core/reinforcement_learning/
trust_region.rs

1// Trust Region Methods for Policy Optimization
2//
3// This module implements trust region methods including TRPO (Trust Region Policy Optimization)
4// and other constrained optimization techniques for policy learning.
5
6use super::{unflatten_named, PolicyNetwork, RLOptimizationMetrics};
7use crate::error::{OptimError, Result};
8use scirs2_core::ndarray::{Array1, Array2, ScalarOperand};
9use scirs2_core::numeric::Float;
10use std::fmt::Debug;
11
12/// Smallest magnitude treated as non-zero by the iterative solvers.
13///
14/// Anything at or below this is treated as an exact zero so the conjugate
15/// gradient never forms `0/0` (which would silently poison the whole step with
16/// NaNs). Comparisons are written as `!(x > tiny())` so NaN inputs also stop.
17fn tiny<T: Float>() -> T {
18    T::from(1e-30).unwrap_or_else(T::epsilon)
19}
20
21/// A mutable closure that evaluates the policy's surrogate objective at the
22/// current parameters. `None` falls back to the quadratic model surrogate
23/// `m(x) = gᵀx − ½ xᵀFx` instead of calling into the policy/environment.
24type SurrogateFn<'a, P, T> = dyn FnMut(&P) -> Result<T> + 'a;
25
26/// Trust region methods
27#[derive(Debug, Clone, Copy)]
28pub enum TrustRegionMethod {
29    /// Trust Region Policy Optimization (TRPO)
30    TRPO,
31
32    /// Constrained Policy Optimization (CPO)
33    CPO,
34
35    /// Projection-based trust region
36    Projection,
37
38    /// Natural gradient with trust region
39    NaturalGradient,
40}
41
42/// Trust region configuration
43#[derive(Debug, Clone)]
44pub struct TrustRegionConfig<T: Float + Debug + Send + Sync + 'static> {
45    /// Trust region method
46    pub method: TrustRegionMethod,
47
48    /// Maximum KL divergence
49    pub max_kl: T,
50
51    /// Conjugate gradient parameters
52    pub cg_iters: usize,
53    pub cg_damping: T,
54    pub cg_tolerance: T,
55
56    /// Line search parameters
57    pub max_backtracks: usize,
58    pub backtrack_coeff: T,
59    pub accept_ratio: T,
60
61    /// Natural gradient Fisher information matrix estimation
62    pub fisher_subsample_freq: usize,
63    pub fisher_reg: T,
64}
65
66impl<T: Float + Debug + Send + Sync + 'static> Default for TrustRegionConfig<T> {
67    fn default() -> Self {
68        Self {
69            method: TrustRegionMethod::TRPO,
70            max_kl: T::from(0.01).unwrap_or_else(|| T::zero()),
71            cg_iters: 10,
72            cg_damping: T::from(0.1).unwrap_or_else(|| T::zero()),
73            cg_tolerance: T::from(1e-8).unwrap_or_else(|| T::zero()),
74            max_backtracks: 10,
75            backtrack_coeff: T::from(0.5).unwrap_or_else(|| T::zero()),
76            accept_ratio: T::from(0.1).unwrap_or_else(|| T::zero()),
77            fisher_subsample_freq: 1,
78            fisher_reg: T::from(1e-5).unwrap_or_else(|| T::zero()),
79        }
80    }
81}
82
83/// Trust region optimizer
84pub struct TrustRegionOptimizer<T: Float + Debug + Send + Sync + 'static, P: PolicyNetwork<T>> {
85    /// Configuration
86    config: TrustRegionConfig<T>,
87
88    /// Policy network
89    policy: P,
90
91    /// Per-sample score vectors used for the empirical Fisher Information Matrix.
92    ///
93    /// Each ROW is a per-sample score vector `g_i = ∇_θ log π(a_i | s_i)` and the
94    /// number of columns equals the policy parameter dimension `d`. When present,
95    /// the empirical Fisher estimate `F̂ = (1/N) Σ_i g_i g_iᵀ` is used to compute
96    /// Fisher-vector products. When `None`, the optimizer falls back to an identity
97    /// Fisher (see [`TrustRegionOptimizer::fisher_vector_product`]).
98    score_samples: Option<Array2<T>>,
99
100    /// Linearized safety constraint used by [`TrustRegionMethod::CPO`].
101    ///
102    /// `Some((b, c))` where `b = ∇_θ J_C(π)` is the cost surrogate gradient and
103    /// `c = J_C(π) − d` is the current constraint surplus (positive ⇒ violated).
104    /// The CPO step enforces `c + bᵀx ≤ 0` alongside the KL trust region.
105    cost_constraint: Option<(Array1<T>, T)>,
106
107    /// Natural gradient state
108    natural_grad_state: NaturalGradientState<T>,
109
110    /// Update counter
111    update_count: usize,
112}
113
114/// Outcome of a single trust-region step.
115#[derive(Debug, Clone)]
116pub struct TrustRegionStepReport<T: Float + Debug + Send + Sync + 'static> {
117    /// Whether a step was accepted (a rejected line search applies **no** update).
118    pub accepted: bool,
119
120    /// Backtracking coefficient of the accepted step (`1` = full step).
121    pub step_scale: T,
122
123    /// Quadratic-model KL of the applied step (`0` when nothing was applied).
124    pub kl: T,
125
126    /// Measured surrogate improvement of the applied step.
127    pub surrogate_improvement: T,
128
129    /// Number of backtracking iterations performed.
130    pub backtracks: usize,
131}
132
133/// Natural gradient computation state
134#[derive(Debug, Clone)]
135pub struct NaturalGradientState<T: Float + Debug + Send + Sync + 'static> {
136    /// Previous gradients for momentum
137    pub prev_gradients: Option<Array1<T>>,
138
139    /// Momentum coefficient
140    pub momentum: T,
141
142    /// Adaptive learning rate state
143    pub adaptive_lr_state: AdaptiveLRState<T>,
144}
145
146/// Adaptive learning rate state
147#[derive(Debug, Clone)]
148pub struct AdaptiveLRState<T: Float + Debug + Send + Sync + 'static> {
149    /// Current learning rate
150    pub learning_rate: T,
151
152    /// Learning rate adaptation factor
153    pub adapt_factor: T,
154
155    /// Success counter for adaptation
156    pub success_count: usize,
157
158    /// Failure counter for adaptation
159    pub failure_count: usize,
160}
161
162impl<
163        T: Float + Debug + Send + Sync + std::iter::Sum + ScalarOperand + 'static,
164        P: PolicyNetwork<T>,
165    > TrustRegionOptimizer<T, P>
166{
167    /// Create a new trust region optimizer
168    pub fn new(config: TrustRegionConfig<T>, policy: P) -> Self {
169        Self {
170            config,
171            policy,
172            score_samples: None,
173            cost_constraint: None,
174            natural_grad_state: NaturalGradientState {
175                prev_gradients: None,
176                momentum: T::from(0.9).unwrap_or_else(|| T::zero()),
177                adaptive_lr_state: AdaptiveLRState {
178                    learning_rate: T::from(0.01).unwrap_or_else(|| T::zero()),
179                    adapt_factor: T::from(1.5).unwrap_or_else(|| T::zero()),
180                    success_count: 0,
181                    failure_count: 0,
182                },
183            },
184            update_count: 0,
185        }
186    }
187
188    /// Feed per-sample score vectors for the empirical Fisher Information Matrix.
189    ///
190    /// Each row of `samples` is a per-sample score vector
191    /// `g_i = ∇_θ log π(a_i | s_i)` whose length must equal the policy parameter
192    /// dimension. These are consumed by `Self::fisher_vector_product` to form the
193    /// empirical estimate `F̂ = (1/N) Σ_i g_i g_iᵀ` without ever materializing the
194    /// dense `d × d` matrix.
195    pub fn set_score_samples(&mut self, samples: Array2<T>) {
196        self.score_samples = Some(samples);
197    }
198
199    /// Clear any stored score samples, reverting the Fisher-vector product to the
200    /// identity-Fisher fallback.
201    pub fn clear_score_samples(&mut self) {
202        self.score_samples = None;
203    }
204
205    /// Install the linearized safety constraint required by CPO.
206    ///
207    /// * `cost_gradient` — `b = ∇_θ J_C(π)`, the ascent direction of the expected
208    ///   cost surrogate (same flat layout as the objective gradient).
209    /// * `cost_surplus` — `c = J_C(π) − d`, i.e. how far the current policy is
210    ///   *above* the cost limit. Positive means the constraint is violated.
211    ///
212    /// The CPO step then solves
213    /// `max gᵀx s.t. c + bᵀx ≤ 0, ½ xᵀFx ≤ δ`.
214    pub fn set_cost_constraint(&mut self, cost_gradient: Array1<T>, cost_surplus: T) {
215        self.cost_constraint = Some((cost_gradient, cost_surplus));
216    }
217
218    /// Remove the CPO safety constraint.
219    pub fn clear_cost_constraint(&mut self) {
220        self.cost_constraint = None;
221    }
222
223    /// Perform trust region update.
224    ///
225    /// `gradients` is the **ascent** direction of the surrogate objective (e.g.
226    /// `∇_θ E[log π(a|s)·A(s,a)]`); the step taken moves *along* it, subject to
227    /// the KL trust region.
228    pub fn update(&mut self, gradients: &Array1<T>) -> Result<RLOptimizationMetrics<T>> {
229        match self.config.method {
230            TrustRegionMethod::TRPO => self.update_trpo(gradients),
231            TrustRegionMethod::CPO => self.update_cpo(gradients),
232            TrustRegionMethod::Projection => self.update_projection(gradients),
233            TrustRegionMethod::NaturalGradient => self.update_natural_gradient(gradients),
234        }
235    }
236
237    /// TRPO update judged against the *quadratic model* of the surrogate.
238    ///
239    /// Without trajectory data the optimizer cannot re-evaluate the true
240    /// surrogate, so the line search scores candidates with the second-order
241    /// model `m(x) = gᵀx − ½ xᵀFx`. Use
242    /// [`Self::update_trpo_with_surrogate`] to line-search against the real
243    /// surrogate (that is what
244    /// [`super::policy_gradient::PolicyGradientOptimizer`] does).
245    fn update_trpo(&mut self, gradients: &Array1<T>) -> Result<RLOptimizationMetrics<T>> {
246        let report = self.trpo_step(gradients, None::<&mut SurrogateFn<'_, P, T>>)?;
247        self.update_count += 1;
248        Ok(Self::metrics_from_report(&report))
249    }
250
251    /// TRPO update whose backtracking line search evaluates a **real** surrogate.
252    ///
253    /// `surrogate` is called with the policy *after* a candidate step has been
254    /// applied and must return the value of the objective being maximized
255    /// (typically `E[ (π(a|s)/π_old(a|s)) · A(s,a) ]`). A candidate is accepted
256    /// only when
257    ///
258    /// * the quadratic-model KL stays within `max_kl`,
259    /// * the surrogate actually improved, and
260    /// * the improvement ratio `actual / expected` exceeds `accept_ratio`.
261    ///
262    /// If every candidate fails, the policy is left **exactly** where it started —
263    /// a rejected step is never applied.
264    pub fn update_trpo_with_surrogate<F>(
265        &mut self,
266        gradients: &Array1<T>,
267        mut surrogate: F,
268    ) -> Result<RLOptimizationMetrics<T>>
269    where
270        F: FnMut(&P) -> Result<T>,
271    {
272        let report = self.trpo_step(gradients, Some(&mut surrogate))?;
273        self.update_count += 1;
274        Ok(Self::metrics_from_report(&report))
275    }
276
277    fn metrics_from_report(report: &TrustRegionStepReport<T>) -> RLOptimizationMetrics<T> {
278        let mut metrics = RLOptimizationMetrics {
279            kl_divergence: Some(report.kl),
280            ..Default::default()
281        };
282        metrics.policy_loss = -report.surrogate_improvement;
283        metrics
284            .custom_metrics
285            .insert("step_scale".to_string(), report.step_scale);
286        metrics.custom_metrics.insert(
287            "line_search_accepted".to_string(),
288            if report.accepted { T::one() } else { T::zero() },
289        );
290        metrics
291    }
292
293    /// Shared TRPO machinery: natural gradient, `β = √(2δ / sᵀFs)` initial step,
294    /// then backtracking line search with acceptance test.
295    fn trpo_step(
296        &mut self,
297        gradients: &Array1<T>,
298        surrogate: Option<&mut SurrogateFn<'_, P, T>>,
299    ) -> Result<TrustRegionStepReport<T>> {
300        // s ≈ F⁻¹g.
301        let natural_grad = self.compute_natural_gradient(gradients)?;
302        let fvp = self.fisher_vector_product(&natural_grad)?;
303        let shs = self.dot(&natural_grad, &fvp);
304
305        // A non-positive curvature means the quadratic model is useless here; the
306        // only safe action is to take no step at all.
307        if !matches!(
308            shs.partial_cmp(&tiny::<T>()),
309            Some(std::cmp::Ordering::Greater)
310        ) {
311            return Ok(TrustRegionStepReport {
312                accepted: false,
313                step_scale: T::zero(),
314                kl: T::zero(),
315                surrogate_improvement: T::zero(),
316                backtracks: 0,
317            });
318        }
319
320        // Full step: the largest multiple of s whose quadratic KL equals δ.
321        let two = T::from(2.0).unwrap_or_else(|| T::one() + T::one());
322        let beta = (two * self.config.max_kl / shs).sqrt();
323        if !beta.is_finite() {
324            return Err(OptimError::ComputationError(
325                "TRPO step size sqrt(2δ/sᵀFs) is not finite".to_string(),
326            ));
327        }
328        let full_step = &natural_grad * beta;
329
330        self.line_search(gradients, &full_step, surrogate, None)
331    }
332
333    /// CPO (Constrained Policy Optimization) update.
334    ///
335    /// Solves the linearized safety problem of Achiam et al. (2017)
336    ///
337    /// ```text
338    /// max_x gᵀx    s.t.   c + bᵀx ≤ 0 ,   ½ xᵀFx ≤ δ
339    /// ```
340    ///
341    /// with `q = gᵀF⁻¹g`, `r = gᵀF⁻¹b`, `s = bᵀF⁻¹b`:
342    ///
343    /// * **Infeasible** (`c > 0` and `c²/s > 2δ`): no step inside the trust region
344    ///   can restore feasibility, so CPO takes the pure recovery step
345    ///   `x = −√(2δ/s)·F⁻¹b`, which reduces the cost as fast as the trust region
346    ///   allows.
347    /// * **Feasible**: both constraints active gives `λ = √(A/B)` with
348    ///   `A = q − r²/s`, `B = 2δ − c²/s`, and `ν = (r + λc)/s`. If `ν < 0` the cost
349    ///   constraint is inactive and the step degenerates to plain TRPO
350    ///   (`ν = 0`, `λ = √(q/2δ)`). The step is `x = (F⁻¹g − ν F⁻¹b)/λ`.
351    ///
352    /// Requires [`Self::set_cost_constraint`]; without it there is no cost signal
353    /// and the method returns [`OptimError::UnsupportedOperation`] rather than
354    /// silently running unconstrained TRPO under a "CPO" label.
355    fn update_cpo(&mut self, gradients: &Array1<T>) -> Result<RLOptimizationMetrics<T>> {
356        let (cost_gradient, cost_surplus) =
357            match self.cost_constraint.clone() {
358                Some(pair) => pair,
359                None => return Err(OptimError::UnsupportedOperation(
360                    "CPO requires a safety constraint: call set_cost_constraint(cost_gradient, \
361                     cost_surplus) before update(), or select TrustRegionMethod::TRPO for the \
362                     unconstrained problem"
363                        .to_string(),
364                )),
365            };
366
367        if cost_gradient.len() != gradients.len() {
368            return Err(OptimError::DimensionMismatch(format!(
369                "cost gradient length ({}) does not match objective gradient length ({})",
370                cost_gradient.len(),
371                gradients.len()
372            )));
373        }
374
375        let two = T::from(2.0).unwrap_or_else(|| T::one() + T::one());
376        let delta = self.config.max_kl;
377
378        let hinv_g = self.conjugate_gradient(gradients)?;
379        let hinv_b = self.conjugate_gradient(&cost_gradient)?;
380
381        let q = self.dot(gradients, &hinv_g);
382        let r = self.dot(gradients, &hinv_b);
383        let s = self.dot(&cost_gradient, &hinv_b);
384
385        // No usable cost curvature ⇒ the constraint carries no information here.
386        if !matches!(
387            s.partial_cmp(&tiny::<T>()),
388            Some(std::cmp::Ordering::Greater)
389        ) {
390            let report = self.trpo_step(gradients, None::<&mut SurrogateFn<'_, P, T>>)?;
391            self.update_count += 1;
392            return Ok(Self::metrics_from_report(&report));
393        }
394
395        let c = cost_surplus;
396        let b_coeff = two * delta - c * c / s;
397
398        let step = if c > T::zero()
399            && !matches!(
400                b_coeff.partial_cmp(&T::zero()),
401                Some(std::cmp::Ordering::Greater)
402            ) {
403            // Infeasible: recovery step straight down the cost gradient.
404            let scale = (two * delta / s).sqrt();
405            &hinv_b * (-scale)
406        } else {
407            let a_coeff = q - r * r / s;
408            let mut lambda = if a_coeff > T::zero() && b_coeff > T::zero() {
409                (a_coeff / b_coeff).sqrt()
410            } else {
411                (q / (two * delta)).sqrt()
412            };
413            let mut nu = (r + lambda * c) / s;
414            if nu < T::zero() {
415                // Cost constraint inactive at the optimum ⇒ plain TRPO step.
416                nu = T::zero();
417                lambda = (q / (two * delta)).sqrt();
418            }
419            if !matches!(
420                lambda.partial_cmp(&tiny::<T>()),
421                Some(std::cmp::Ordering::Greater)
422            ) || !lambda.is_finite()
423            {
424                return Ok(Self::metrics_from_report(&TrustRegionStepReport {
425                    accepted: false,
426                    step_scale: T::zero(),
427                    kl: T::zero(),
428                    surrogate_improvement: T::zero(),
429                    backtracks: 0,
430                }));
431            }
432            (&hinv_g - &(&hinv_b * nu)) / lambda
433        };
434
435        let report = self.line_search(
436            gradients,
437            &step,
438            None::<&mut SurrogateFn<'_, P, T>>,
439            Some((&cost_gradient, c)),
440        )?;
441        self.update_count += 1;
442
443        let mut metrics = Self::metrics_from_report(&report);
444        metrics.custom_metrics.insert("cost_surplus".to_string(), c);
445        Ok(metrics)
446    }
447
448    /// Projection-based trust region update
449    fn update_projection(&mut self, gradients: &Array1<T>) -> Result<RLOptimizationMetrics<T>> {
450        // Project gradients onto trust region
451        let projected_grad = self.project_to_trust_region(gradients)?;
452        self.apply_parameter_update(&projected_grad)?;
453
454        Ok(RLOptimizationMetrics::default())
455    }
456
457    /// Natural gradient update
458    fn update_natural_gradient(
459        &mut self,
460        gradients: &Array1<T>,
461    ) -> Result<RLOptimizationMetrics<T>> {
462        let natural_grad = self.compute_natural_gradient(gradients)?;
463        let lr = self.natural_grad_state.adaptive_lr_state.learning_rate;
464        let update_step = &natural_grad * lr;
465
466        self.apply_parameter_update(&update_step)?;
467
468        Ok(RLOptimizationMetrics::default())
469    }
470
471    /// Compute natural gradient using conjugate gradient method
472    fn compute_natural_gradient(&mut self, gradients: &Array1<T>) -> Result<Array1<T>> {
473        // Solve F * x = g for natural gradient x, where F is Fisher information matrix
474        self.conjugate_gradient(gradients)
475    }
476
477    /// Conjugate gradient solver for the (damped) Fisher information system.
478    ///
479    /// Guards every division that the textbook recurrence performs:
480    /// * a zero right-hand side returns the exact solution `x = 0` immediately
481    ///   instead of computing `0/0` for `α`;
482    /// * a vanishing (or non-finite) curvature `pᵀAp` breaks out with the best
483    ///   iterate found so far rather than injecting `±inf`/NaN into `x`;
484    /// * `β = rsnew/rsold` is only evaluated while `rsold` is strictly positive.
485    fn conjugate_gradient(&self, b: &Array1<T>) -> Result<Array1<T>> {
486        let n = b.len();
487        let mut x = Array1::zeros(n);
488        let mut r = b.clone();
489        let mut p = r.clone();
490        let mut rsold = self.dot(&r, &r);
491
492        // ‖b‖ = 0 (or non-finite): x = 0 already solves the system.
493        if !matches!(
494            rsold.partial_cmp(&tiny::<T>()),
495            Some(std::cmp::Ordering::Greater)
496        ) {
497            return Ok(x);
498        }
499
500        for _i in 0..self.config.cg_iters {
501            let ap = self.fisher_vector_product(&p)?;
502            let pap = self.dot(&p, &ap);
503
504            // Zero / negative / non-finite curvature: stop with the current iterate.
505            if !matches!(
506                pap.abs().partial_cmp(&tiny::<T>()),
507                Some(std::cmp::Ordering::Greater)
508            ) || !pap.is_finite()
509            {
510                break;
511            }
512
513            let alpha = rsold / pap;
514
515            x = &x + &(&p * alpha);
516            r = &r - &(&ap * alpha);
517
518            let rsnew = self.dot(&r, &r);
519
520            if rsnew.sqrt() < self.config.cg_tolerance {
521                break;
522            }
523            if !matches!(
524                rsnew.partial_cmp(&tiny::<T>()),
525                Some(std::cmp::Ordering::Greater)
526            ) {
527                break;
528            }
529
530            let beta = rsnew / rsold;
531            p = &r + &(&p * beta);
532            rsold = rsnew;
533        }
534
535        Ok(x)
536    }
537
538    /// Empirical Fisher information matrix vector product.
539    ///
540    /// The Fisher Information Matrix is `F = E[ g gᵀ ]` where
541    /// `g = ∇_θ log π(a | s)` is the score (gradient of the log-likelihood). Given
542    /// `N` per-sample score rows `g_i`, the empirical estimate is
543    /// `F̂ = (1/N) Σ_i g_i g_iᵀ`.
544    ///
545    /// The product `F̂·v` is computed WITHOUT ever forming the dense `d × d` matrix
546    /// by exploiting `g_i g_iᵀ v = g_i (g_i · v)`, giving
547    /// `F̂ v = (1/N) Σ_i g_i (g_i · v)` in `O(N · d)` time and `O(d)` memory.
548    ///
549    /// For conjugate-gradient stability the DAMPED product is returned:
550    /// `F̂·v + cg_damping·v` (the standard TRPO/Hessian-free damping). An optional
551    /// additional ridge `fisher_reg·v` is folded into the estimate so that the
552    /// effective system is `(F̂ + fisher_reg·I + cg_damping·I) v`.
553    ///
554    /// Fallback: if no score samples are available (`None` or an empty matrix),
555    /// the Fisher is treated as the identity and `v + cg_damping·v` is returned.
556    /// This keeps the CG solver well-defined before any empirical data is fed in.
557    fn fisher_vector_product(&self, v: &Array1<T>) -> Result<Array1<T>> {
558        // CG damping is always applied (primary regularization for CG stability).
559        let damping = self.config.cg_damping;
560
561        match &self.score_samples {
562            Some(samples) if samples.nrows() > 0 => {
563                let n_samples = samples.nrows();
564                let dim = samples.ncols();
565
566                if dim != v.len() {
567                    return Err(OptimError::DimensionMismatch(format!(
568                        "Score sample dimension ({}) does not match vector dimension ({})",
569                        dim,
570                        v.len()
571                    )));
572                }
573
574                // Accumulate F̂ v = (1/N) Σ_i g_i (g_i · v) without forming F̂.
575                let mut accum: Array1<T> = Array1::zeros(dim);
576                for row in samples.rows() {
577                    // g_i · v
578                    let proj: T = row.iter().zip(v.iter()).map(|(&g, &x)| g * x).sum();
579                    // accum += g_i * (g_i · v)
580                    for (acc, &g) in accum.iter_mut().zip(row.iter()) {
581                        *acc = *acc + g * proj;
582                    }
583                }
584
585                let inv_n = T::one()
586                    / T::from(n_samples).ok_or_else(|| {
587                        OptimError::ComputationError(
588                            "Failed to convert sample count to scalar type".to_string(),
589                        )
590                    })?;
591                accum.mapv_inplace(|x| x * inv_n);
592
593                // (F̂ + fisher_reg·I + cg_damping·I) v
594                let ridge = self.config.fisher_reg + damping;
595                Ok(&accum + &(v * ridge))
596            }
597            // Identity-Fisher fallback: treat F̂ = I, return (I + cg_damping·I) v.
598            _ => Ok(v + &(v * damping)),
599        }
600    }
601
602    /// Backtracking line search over `full_step · backtrack_coeff^j`.
603    ///
604    /// Each candidate is *applied* to the policy, scored, and **reverted unless it
605    /// is accepted** — the previous implementation applied the last candidate it
606    /// examined even after rejecting it, which silently pushed the policy outside
607    /// the trust region. Acceptance requires all of:
608    ///
609    /// * quadratic-model KL `½ xᵀFx ≤ max_kl`,
610    /// * a strictly positive surrogate improvement,
611    /// * improvement ratio `actual / expected > accept_ratio` (`expected = gᵀx`),
612    /// * and, when a CPO cost constraint is supplied, `c + bᵀx ≤ 0`.
613    ///
614    /// If no candidate is accepted the policy is left untouched (zero step).
615    fn line_search(
616        &mut self,
617        gradients: &Array1<T>,
618        full_step: &Array1<T>,
619        mut surrogate: Option<&mut SurrogateFn<'_, P, T>>,
620        cost_constraint: Option<(&Array1<T>, T)>,
621    ) -> Result<TrustRegionStepReport<T>> {
622        // Baseline surrogate value at the current parameters.
623        let base = match surrogate {
624            Some(ref mut f) => f(&self.policy)?,
625            None => T::zero(),
626        };
627
628        let mut scale = T::one();
629        for attempt in 0..self.config.max_backtracks.max(1) {
630            let step = full_step * scale;
631
632            // Quadratic-model KL of this candidate (½ stepᵀ F step).
633            let kl = self.estimate_kl_divergence(&step, T::one())?;
634            let expected = self.dot(gradients, &step);
635
636            // Cost feasibility of the linearized safety constraint.
637            let cost_ok = match cost_constraint {
638                Some((cost_gradient, surplus)) => {
639                    surplus + self.dot(cost_gradient, &step) <= T::zero()
640                }
641                None => true,
642            };
643
644            self.apply_parameter_update(&step)?;
645
646            let value = match surrogate {
647                Some(ref mut f) => f(&self.policy)?,
648                // Model surrogate m(x) = gᵀx − ½ xᵀFx (base is 0).
649                None => expected - kl,
650            };
651            let actual = value - base;
652
653            let ratio = if expected > tiny::<T>() {
654                actual / expected
655            } else {
656                T::neg_infinity()
657            };
658
659            let accept = kl <= self.config.max_kl
660                && cost_ok
661                && actual > T::zero()
662                && ratio > self.config.accept_ratio;
663
664            if accept {
665                self.natural_grad_state.adaptive_lr_state.success_count += 1;
666                return Ok(TrustRegionStepReport {
667                    accepted: true,
668                    step_scale: scale,
669                    kl,
670                    surrogate_improvement: actual,
671                    backtracks: attempt,
672                });
673            }
674
675            // Rejected: undo the candidate before trying a shorter one.
676            self.apply_parameter_update(&(&step * -T::one()))?;
677            scale = scale * self.config.backtrack_coeff;
678        }
679
680        // Nothing acceptable: take no step at all.
681        self.natural_grad_state.adaptive_lr_state.failure_count += 1;
682        Ok(TrustRegionStepReport {
683            accepted: false,
684            step_scale: T::zero(),
685            kl: T::zero(),
686            surrogate_improvement: T::zero(),
687            backtracks: self.config.max_backtracks.max(1),
688        })
689    }
690
691    /// Estimate KL divergence for proposed update
692    fn estimate_kl_divergence(&self, direction: &Array1<T>, stepsize: T) -> Result<T> {
693        // Quadratic approximation: KL ≈ 0.5 * d^T * F * d * step_size^2
694        let fvp = self.fisher_vector_product(direction)?;
695        let kl_estimate = T::from(0.5).unwrap_or_else(|| T::zero())
696            * self.dot(direction, &fvp)
697            * stepsize
698            * stepsize;
699        Ok(kl_estimate)
700    }
701
702    /// Project gradients onto trust region
703    fn project_to_trust_region(&self, gradients: &Array1<T>) -> Result<Array1<T>> {
704        let grad_norm = self.norm(gradients);
705        let max_norm = (T::from(2.0).unwrap_or_else(|| T::zero()) * self.config.max_kl).sqrt();
706
707        if grad_norm <= max_norm {
708            Ok(gradients.clone())
709        } else {
710            Ok(gradients * (max_norm / grad_norm))
711        }
712    }
713
714    /// Apply a flat parameter update onto the policy network.
715    ///
716    /// The flat `update` vector is mapped back onto the policy's named parameters.
717    /// Keys are visited in SORTED order for determinism, the flat update is sliced
718    /// into contiguous chunks matching each parameter's length, and the resulting
719    /// `HashMap<String, Array1<T>>` is forwarded to `policy.update_parameters`.
720    ///
721    /// Returns an error if the flat update length does not equal the total
722    /// parameter count across all named parameters.
723    fn apply_parameter_update(&mut self, update: &Array1<T>) -> Result<()> {
724        let params = self.policy.get_parameters();
725        let deltas = unflatten_named(&params, update)?;
726        self.policy.update_parameters(&deltas)
727    }
728
729    /// Dot product
730    fn dot(&self, a: &Array1<T>, b: &Array1<T>) -> T {
731        a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum()
732    }
733
734    /// Vector norm
735    fn norm(&self, v: &Array1<T>) -> T {
736        self.dot(v, v).sqrt()
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::super::{ActionDistribution, DistributionType, PolicyEvaluation};
743    use super::*;
744    use approx::assert_abs_diff_eq;
745    use scirs2_core::ndarray::{arr1, arr2};
746    use std::cell::RefCell;
747    use std::collections::HashMap;
748
749    /// Minimal mock policy network over a tiny parameter map (`"w"`, length 3).
750    ///
751    /// Only `get_parameters` / `update_parameters` carry real behavior; the
752    /// distribution-related trait methods return trivially valid values. The last
753    /// gradients passed to `update_parameters` are recorded via interior mutability
754    /// so tests can assert the parameter update was actually applied.
755    struct MockPolicy {
756        params: HashMap<String, Array1<f64>>,
757        last_gradients: RefCell<Option<HashMap<String, Array1<f64>>>>,
758    }
759
760    impl MockPolicy {
761        fn new() -> Self {
762            let mut params = HashMap::new();
763            params.insert("w".to_string(), arr1(&[0.0, 0.0, 0.0]));
764            Self {
765                params,
766                last_gradients: RefCell::new(None),
767            }
768        }
769    }
770
771    impl PolicyNetwork<f64> for MockPolicy {
772        fn evaluate_actions(
773            &self,
774            _observations: &Array2<f64>,
775            _actions: &Array2<f64>,
776        ) -> Result<PolicyEvaluation<f64>> {
777            Ok(PolicyEvaluation {
778                log_probs: arr1(&[0.0]),
779                entropy: arr1(&[0.0]),
780                metrics: HashMap::new(),
781            })
782        }
783
784        fn get_action_distribution(
785            &self,
786            _observations: &Array2<f64>,
787        ) -> Result<ActionDistribution<f64>> {
788            Ok(ActionDistribution {
789                mean: None,
790                std: None,
791                logits: None,
792                distribution_type: DistributionType::Gaussian,
793            })
794        }
795
796        fn update_parameters(&mut self, gradients: &HashMap<String, Array1<f64>>) -> Result<()> {
797            // Apply and record the update for assertions.
798            for (key, grad) in gradients {
799                if let Some(p) = self.params.get_mut(key) {
800                    *p = &*p + grad;
801                }
802            }
803            *self.last_gradients.borrow_mut() = Some(gradients.clone());
804            Ok(())
805        }
806
807        fn get_parameters(&self) -> HashMap<String, Array1<f64>> {
808            self.params.clone()
809        }
810    }
811
812    fn make_optimizer(cg_damping: f64) -> TrustRegionOptimizer<f64, MockPolicy> {
813        // Isolate the cg_damping contribution from the additional ridge for tests.
814        let config = TrustRegionConfig::<f64> {
815            cg_damping,
816            fisher_reg: 0.0,
817            ..Default::default()
818        };
819        TrustRegionOptimizer::new(config, MockPolicy::new())
820    }
821
822    /// Reference dense computation of `(1/N) Σ_i g_i (g_i · v) + cg_damping · v`.
823    fn reference_fvp(samples: &Array2<f64>, v: &Array1<f64>, damping: f64) -> Array1<f64> {
824        let n = samples.nrows();
825        let dim = samples.ncols();
826        let mut out = Array1::<f64>::zeros(dim);
827        for row in samples.rows() {
828            let proj: f64 = row.iter().zip(v.iter()).map(|(&g, &x)| g * x).sum();
829            for (o, &g) in out.iter_mut().zip(row.iter()) {
830                *o += g * proj;
831            }
832        }
833        out.mapv_inplace(|x| x / n as f64);
834        &out + &(v * damping)
835    }
836
837    #[test]
838    fn test_fisher_vector_product_matches_empirical_formula() {
839        let damping = 0.1;
840        let mut opt = make_optimizer(damping);
841
842        // Two score samples over a 3-dim parameter space.
843        let samples = arr2(&[[1.0, 2.0, 3.0], [0.5, -1.0, 2.0]]);
844        opt.set_score_samples(samples.clone());
845
846        let v = arr1(&[0.3, -0.7, 1.1]);
847        let got = opt
848            .fisher_vector_product(&v)
849            .expect("fisher-vector product");
850        let expected = reference_fvp(&samples, &v, damping);
851
852        assert_eq!(got.len(), expected.len());
853        for (g, e) in got.iter().zip(expected.iter()) {
854            assert_abs_diff_eq!(*g, *e, epsilon = 1e-10);
855        }
856    }
857
858    #[test]
859    fn test_fisher_vector_product_identity_fallback() {
860        let damping = 0.1;
861        let opt = make_optimizer(damping);
862        // No score samples set => identity Fisher: (I + cg_damping I) v.
863        let v = arr1(&[1.0, -2.0, 4.0]);
864        let got = opt
865            .fisher_vector_product(&v)
866            .expect("fisher-vector product");
867        let expected = &v + &(&v * damping);
868        for (g, e) in got.iter().zip(expected.iter()) {
869            assert_abs_diff_eq!(*g, *e, epsilon = 1e-12);
870        }
871
872        // Empty score matrix also triggers the fallback.
873        let mut opt2 = make_optimizer(damping);
874        opt2.set_score_samples(Array2::<f64>::zeros((0, 3)));
875        let got2 = opt2
876            .fisher_vector_product(&v)
877            .expect("fisher-vector product");
878        for (g, e) in got2.iter().zip(expected.iter()) {
879            assert_abs_diff_eq!(*g, *e, epsilon = 1e-12);
880        }
881    }
882
883    #[test]
884    fn test_conjugate_gradient_solves_damped_system() {
885        let damping = 0.5;
886        let mut opt = make_optimizer(damping);
887        let samples = arr2(&[[1.0, 0.5, -0.3], [0.2, 1.5, 0.7], [-0.5, 0.1, 1.2]]);
888        opt.set_score_samples(samples.clone());
889
890        let b = arr1(&[1.0, -2.0, 0.5]);
891        let x = opt.conjugate_gradient(&b).expect("conjugate gradient");
892
893        // Residual ||(F̂ + λI) x − b|| must be small: fisher_vector_product already
894        // applies the damped operator (F̂ + cg_damping·I) since fisher_reg = 0.
895        let ax = opt
896            .fisher_vector_product(&x)
897            .expect("fisher-vector product");
898        let residual: f64 = ax
899            .iter()
900            .zip(b.iter())
901            .map(|(&a, &bv)| (a - bv) * (a - bv))
902            .sum::<f64>()
903            .sqrt();
904        assert!(
905            residual < 1e-6,
906            "CG residual too large: {residual} (x = {x:?})"
907        );
908    }
909
910    #[test]
911    fn test_apply_parameter_update_forwards_split_gradient() {
912        let damping = 0.1;
913        let mut opt = make_optimizer(damping);
914
915        // Flat update of length 3 maps onto the single "w" parameter (len 3).
916        let update = arr1(&[0.1, 0.2, 0.3]);
917        opt.apply_parameter_update(&update).expect("apply update");
918
919        // The mock recorded the forwarded gradient map.
920        let recorded = opt.policy.last_gradients.borrow();
921        let map = recorded.as_ref().expect("update_parameters was not called");
922        let w_grad = map.get("w").expect("missing 'w' gradient");
923        assert_eq!(w_grad.len(), 3);
924        assert_abs_diff_eq!(w_grad[0], 0.1, epsilon = 1e-12);
925        assert_abs_diff_eq!(w_grad[1], 0.2, epsilon = 1e-12);
926        assert_abs_diff_eq!(w_grad[2], 0.3, epsilon = 1e-12);
927
928        // And the policy parameters were actually advanced by the update.
929        let params = opt.policy.get_parameters();
930        let w = params.get("w").expect("w parameter");
931        assert_abs_diff_eq!(w[0], 0.1, epsilon = 1e-12);
932        assert_abs_diff_eq!(w[1], 0.2, epsilon = 1e-12);
933        assert_abs_diff_eq!(w[2], 0.3, epsilon = 1e-12);
934    }
935
936    #[test]
937    fn test_apply_parameter_update_length_mismatch_errors() {
938        let mut opt = make_optimizer(0.1);
939        // Wrong length (4 != 3) must return an error.
940        let bad = arr1(&[0.1, 0.2, 0.3, 0.4]);
941        assert!(opt.apply_parameter_update(&bad).is_err());
942    }
943
944    #[test]
945    fn test_kl_estimate_uses_real_damped_fisher() {
946        let damping = 0.2;
947        let mut opt = make_optimizer(damping);
948        let samples = arr2(&[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
949        opt.set_score_samples(samples.clone());
950
951        let direction = arr1(&[1.0, 1.0, 1.0]);
952        let step = 0.5_f64;
953
954        // KL ≈ 0.5 * dᵀ (F̂ + λI) d * step².
955        let fvp = reference_fvp(&samples, &direction, damping);
956        let quad: f64 = direction.iter().zip(fvp.iter()).map(|(&d, &f)| d * f).sum();
957        let expected = 0.5 * quad * step * step;
958
959        let got = opt
960            .estimate_kl_divergence(&direction, step)
961            .expect("kl estimate");
962        assert_abs_diff_eq!(got, expected, epsilon = 1e-10);
963    }
964}