Skip to main content

gam_geometry/
optimizer.rs

1use gam_linalg::roundoff::accumulation_band;
2use ndarray::{Array1, ArrayView1};
3use opt::TrustRegionPolicy;
4
5use crate::manifold::{GeometryResult, RiemannianManifold, check_len, quad_form};
6
7/// Linear factor of the Steihaug truncated-CG forcing sequence: the inner CG
8/// solve is terminated once the residual drops to `min(η·‖r₀‖, ‖r₀‖²)`. The
9/// quadratic `‖r₀‖²` term gives the super-linear convergence of an inexact
10/// Newton step near the optimum, while `η·‖r₀‖` caps wasted inner work far from
11/// it (Nocedal & Wright, *Numerical Optimization*, §7.1, eq. 7.3).
12const STEIHAUG_CG_FORCING_FACTOR: f64 = 1.0e-2;
13
14pub trait RiemannianObjective {
15    fn value_gradient(&mut self, point: ArrayView1<'_, f64>) -> GeometryResult<(f64, Array1<f64>)>;
16
17    /// Riemannian Hessian–vector product `H(x)·v` for a tangent direction `v`
18    /// at `point`, returned in the same ambient/tangent coordinates as the
19    /// gradient.
20    ///
21    /// This is what upgrades the trust-region subproblem from a Cauchy-point
22    /// step (the exact minimizer of the *linear* model along the steepest
23    /// descent direction) to a Steihaug truncated-CG step that exploits real
24    /// curvature. An objective that exposes no second-order information returns
25    /// `None` (the default), and the trust region transparently falls back to
26    /// the Cauchy point — never to plain clipped steepest descent, which has no
27    /// model, no predicted/actual reduction ratio, and no accept/reject.
28    ///
29    /// The Riemannian-Hessian quadratic model the trust region builds from this
30    /// product is a valid second-order model of `f` only along a (≥)second-order
31    /// retraction (the exponential map, or any retraction with
32    /// [`RiemannianManifold::retraction_is_second_order`] `== true`). On a
33    /// manifold whose `retract` is only FIRST-order (e.g. the Stiefel/Grassmann
34    /// QR retraction) the second derivative of the pullback `f∘R_x` is not the
35    /// Riemannian Hessian, so the trust region ignores this curvature and uses
36    /// the first-order-correct Cauchy model instead (issue #956).
37    fn hessian_vector_product(
38        &mut self,
39        point: ArrayView1<'_, f64>,
40        tangent: ArrayView1<'_, f64>,
41    ) -> GeometryResult<Option<Array1<f64>>> {
42        // Validate the shapes the contract requires (a tangent at `point`), then
43        // report "no curvature available" so the trust region selects the
44        // Cauchy point. We never fabricate a Hessian here.
45        check_len("hessian_vector_product tangent", tangent.len(), point.len())?;
46        Ok(None)
47    }
48}
49
50/// Metric inner product `g_x(a, b) = aᵀ G(x) b` using the manifold metric
51/// tensor at `point`. For manifolds whose metric is the ambient identity
52/// (Euclidean, Sphere, Circle, Torus, …) this reduces to the Euclidean dot
53/// product; for a genuine Riemannian metric (e.g. the affine-invariant SPD
54/// metric) it evaluates the correct geometric inner product on the tangent
55/// space. Every norm and inner product in both optimizers below routes through
56/// this so the algorithms are metric-correct on curved manifolds.
57fn g_inner(
58    manifold: &dyn RiemannianManifold,
59    point: ArrayView1<'_, f64>,
60    a: ArrayView1<'_, f64>,
61    b: ArrayView1<'_, f64>,
62) -> GeometryResult<f64> {
63    let g = manifold.metric_tensor(point)?;
64    Ok(quad_form(g.view(), a, b))
65}
66
67fn g_norm(
68    manifold: &dyn RiemannianManifold,
69    point: ArrayView1<'_, f64>,
70    a: ArrayView1<'_, f64>,
71) -> GeometryResult<f64> {
72    let metric = manifold.metric_tensor(point)?;
73    let metric_times_a = gam_linalg::faer_ndarray::fast_av(&metric.view(), &a);
74    metric_norm_from_product(a, metric_times_a.view())
75}
76
77/// Certify `sqrt(a^T G a)` once the metric product `G a` is available.
78///
79/// The absolute accumulation is part of the backward-error certificate.  It
80/// must itself remain finite: an infinite error scale would make every finite
81/// negative quadratic look like harmless roundoff and could certify a
82/// non-zero vector as having zero norm under an indefinite metric.
83fn metric_norm_from_product(
84    a: ArrayView1<'_, f64>,
85    metric_times_a: ArrayView1<'_, f64>,
86) -> GeometryResult<f64> {
87    check_len("metric norm product", metric_times_a.len(), a.len())?;
88    let mut squared_norm = 0.0_f64;
89    let mut absolute_sum = 0.0_f64;
90    for (&left, &right) in a.iter().zip(metric_times_a.iter()) {
91        let term = left * right;
92        squared_norm += term;
93        absolute_sum += term.abs();
94    }
95    if !squared_norm.is_finite() {
96        return Ok(f64::INFINITY);
97    }
98    if !absolute_sum.is_finite() {
99        return Err(crate::manifold::GeometryError::InvalidPoint(
100            "Riemannian metric norm error bound overflowed",
101        ));
102    }
103    // A Riemannian metric is positive definite. Permit only the backward-error
104    // band of the final dot product; clamping a materially negative quadratic
105    // to zero would falsely turn an indefinite metric into a stationary point.
106    // That band is Wilkinson's for this exact accumulation — both of its inputs,
107    // the term count and the absolute sum, are the loop's own state — so it is
108    // computed rather than guessed. A fixed multiple of EPSILON would be too
109    // tight on a high-dimensional tangent space, rejecting metrics whose
110    // squared norm is zero in exact arithmetic, and needlessly loose on a
111    // low-dimensional one.
112    let negative_roundoff = accumulation_band(a.len(), absolute_sum);
113    if squared_norm < -negative_roundoff {
114        return Err(crate::manifold::GeometryError::InvalidPoint(
115            "Riemannian metric produced a negative squared norm",
116        ));
117    }
118    Ok(squared_norm.max(0.0).sqrt())
119}
120
121/// Shift-invariant relative-gradient stationarity measure
122/// `‖grad_k‖_g / max(‖grad_0‖_g, 1)`, comparing the current Riemannian gradient
123/// norm to the gradient norm at the INITIAL iterate. The initial gradient norm
124/// carries the same *multiplicative* scale the objective and its gradient share
125/// (`f → c·f` ⇒ `grad → c·grad`), so a fixed `grad_tol` still reads as a
126/// *relative* tolerance — but, unlike dividing by `max(|f|, 1)`, `‖grad_0‖` is
127/// invariant under an additive shift `f → f + C`, which leaves the minimizers,
128/// gradient, trust-region model reduction, Armijo slope, and accepted path all
129/// unchanged. Dividing by `|f|` was non-invariant: a large additive constant
130/// inflates the denominator and can falsely certify convergence at a
131/// non-stationary iterate (e.g. `f̃(x) = C + x²` at `x = 1` with `C > 2/τ − 1`),
132/// issue #954. The `max(·, 1)` floor reduces this to the absolute test
133/// `‖grad_k‖ ≤ grad_tol` on a unit-scale objective and preserves the
134/// O(n)-gradient calibration of the profiled REML latent objective (whose
135/// `‖grad_0‖` is itself O(n), issue #879). The non-intrinsic `‖x‖_typ` factor is
136/// dropped: ambient iterate magnitude is not coordinate/chart invariant on a
137/// manifold, so it does not belong in a Riemannian stationarity test. A
138/// non-finite gradient maps to `+∞` so a blown-up iterate is never stationary.
139fn relative_stationarity(grad_norm: f64, grad0_norm: f64) -> f64 {
140    if !grad_norm.is_finite() || !grad0_norm.is_finite() {
141        return f64::INFINITY;
142    }
143    grad_norm / grad0_norm.max(1.0)
144}
145
146/// The context string of the trust-region first-order certificate, shared by the
147/// refusal in [`RiemannianTrustRegion::minimize`] and by callers that re-report
148/// the same verdict from [`TrustRegionTermination`].
149pub const TRUST_REGION_RELATIVE_GRADIENT_CONTEXT: &str =
150    "Riemannian trust-region optimization (relative gradient norm)";
151
152/// Terminal state of a trust-region run: the iterate reached, and the numbers the
153/// first-order certificate was decided against.
154#[derive(Clone, Debug)]
155pub struct TrustRegionTermination {
156    /// The last iterate. Present whether or not the certificate holds — this is
157    /// the work a budget-exhausted run has to hand back.
158    pub point: Array1<f64>,
159    /// Iterations actually executed (zero when the budget was zero).
160    pub iterations: usize,
161    /// Relative stationarity `‖g_final‖ / max(‖g_0‖, 1)` at `point`.
162    pub residual: f64,
163    /// The bound `residual` was compared against.
164    pub tolerance: f64,
165}
166
167impl TrustRegionTermination {
168    /// Whether `point` satisfies the first-order certificate that controls the
169    /// loop. This is the same test `minimize` applies before returning a point.
170    pub fn certifies(&self) -> bool {
171        self.residual <= self.tolerance
172    }
173}
174
175#[derive(Debug, Clone, PartialEq)]
176pub struct RiemannianTrustRegion {
177    /// Initial trust-region radius Δ₀.
178    pub radius: f64,
179    /// Hard cap Δmax on the radius across all iterations.
180    pub max_radius: f64,
181    pub max_iter: usize,
182    pub grad_tol: f64,
183}
184
185impl Default for RiemannianTrustRegion {
186    fn default() -> Self {
187        Self {
188            radius: 1.0,
189            max_radius: 1.0e6,
190            max_iter: 64,
191            grad_tol: 1.0e-8,
192        }
193    }
194}
195
196impl RiemannianTrustRegion {
197    /// A genuine Riemannian trust-region method.
198    ///
199    /// At each iterate `x` we build the quadratic model in the tangent space
200    /// `T_xM`,
201    ///
202    /// ```text
203    ///   m(η) = f(x) + g_x(grad, η) + ½ g_x(η, Hη),
204    /// ```
205    ///
206    /// where `g_x(·,·)` is the manifold metric inner product and `H` is the
207    /// Riemannian Hessian (accessed only through Hessian–vector products). The
208    /// step is the (approximate) solution of the trust-region subproblem
209    ///
210    /// ```text
211    ///   min_{η ∈ T_xM, ‖η‖_g ≤ Δ}  m(η).
212    /// ```
213    ///
214    /// When the objective supplies Hessian–vector products AND the manifold's
215    /// `retract` is at least a second-order retraction
216    /// ([`RiemannianManifold::retraction_is_second_order`]) we solve the
217    /// subproblem with the Steihaug truncated-CG method (stopping at negative
218    /// curvature or the trust-region boundary). Otherwise — no curvature, or a
219    /// first-order retraction whose pullback second derivative is not the
220    /// Riemannian Hessian (issue #956) — we fall back to the Cauchy point: the
221    /// exact minimizer of the model along the steepest-descent direction within
222    /// the trust region (with curvature taken from the model where available,
223    /// and the boundary point of the decreasing linear model otherwise). The
224    /// linear term `Df_x[η]` is retraction-independent, so the Cauchy model
225    /// keeps ρ and the radius control valid along any retraction. Either way
226    /// this is a real model-based step — not clipped descent.
227    ///
228    /// We then form the ratio of actual to predicted reduction
229    ///
230    /// ```text
231    ///   ρ = (f(x) − f(x⁺)) / (m(0) − m(η)),
232    /// ```
233    ///
234    /// accept the step only when `ρ > η₁`, and adapt Δ: shrink on a poor ratio,
235    /// expand on an excellent ratio that reaches the boundary, otherwise hold.
236    /// Only accepted steps are retracted onto the manifold.
237    pub fn minimize(
238        &self,
239        manifold: &dyn RiemannianManifold,
240        objective: &mut dyn RiemannianObjective,
241        initial: ArrayView1<'_, f64>,
242    ) -> GeometryResult<Array1<f64>> {
243        let termination = self.minimize_reporting_termination(manifold, objective, initial)?;
244        if termination.certifies() {
245            Ok(termination.point)
246        } else {
247            Err(crate::manifold::GeometryError::NonConvergence {
248                context: TRUST_REGION_RELATIVE_GRADIENT_CONTEXT,
249                iterations: termination.iterations,
250                residual: termination.residual,
251                tolerance: termination.tolerance,
252            })
253        }
254    }
255
256    /// As [`Self::minimize`], but reporting the terminal iterate alongside the
257    /// first-order verdict instead of discarding it.
258    ///
259    /// `minimize` returns `Err(NonConvergence)` when the terminal point fails the
260    /// relative-gradient certificate, and that error carries the residual but not
261    /// the POINT. A caller whose contract is checkpoint/resume cannot be served
262    /// by it: the work done before the budget ran out is exactly the iterate, and
263    /// with only a residual there is nothing to resume from. Genuine failures —
264    /// a non-finite value, an invalid radius, an objective or manifold error —
265    /// are still `Err` here; only the first-order test is demoted from an error
266    /// to a reported verdict, so `minimize` above reconstructs its own behavior
267    /// exactly and every existing caller is unaffected.
268    pub fn minimize_reporting_termination(
269        &self,
270        manifold: &dyn RiemannianManifold,
271        objective: &mut dyn RiemannianObjective,
272        initial: ArrayView1<'_, f64>,
273    ) -> GeometryResult<TrustRegionTermination> {
274        // Trust-region acceptance and radius control come from `opt`, not
275        // from constants re-declared here. SPEC-22 puts general outer
276        // optimizer work in `opt`, and a trust-region rho-controller is
277        // exactly that: this loop's five constants were bit-for-bit the
278        // ones `TrustRegionPolicy::classic` already ships (accept 0.1,
279        // shrink below 0.25, expand above 0.75 at the boundary, x0.25,
280        // x2.0), so keeping a private copy bought nothing and gave the
281        // radius rule two places to drift apart.
282        let policy = TrustRegionPolicy::classic(self.max_radius);
283        let mut x = initial.to_owned();
284        let d = manifold.ambient_dim();
285        check_len("trust-region initial point", x.len(), d)?;
286        if !(self.radius.is_finite() && self.radius > 0.0) {
287            return Err(crate::manifold::GeometryError::InvalidPoint(
288                "trust-region radius must be finite and positive",
289            ));
290        }
291        if !(self.max_radius.is_finite() && self.max_radius > 0.0) {
292            return Err(crate::manifold::GeometryError::InvalidPoint(
293                "trust-region maximum radius must be finite and positive",
294            ));
295        }
296        if !(self.grad_tol.is_finite() && self.grad_tol >= 0.0) {
297            return Err(crate::manifold::GeometryError::InvalidPoint(
298                "trust-region gradient tolerance must be finite and non-negative",
299            ));
300        }
301
302        // Establish the trust-region invariant `0 < Δ_k ≤ Δmax` *before* the
303        // first step, not just on later expansions. The expansion rule below
304        // caps via `min(·, max_radius)` and contraction only shrinks, so once
305        // `0 < Δ₀ ≤ Δmax` holds we have `0 < Δ_k ≤ Δmax` for all `k` by
306        // induction; every subproblem then obeys `‖η_k‖_g ≤ Δ_k ≤ Δmax`,
307        // restoring `max_radius` as the documented hard cap. A configured
308        // `radius > max_radius` (or a non-finite `radius`) would otherwise let
309        // the very first Cauchy/Steihaug step overshoot the advertised maximum,
310        // so we clamp the initial radius into `(0, max_radius]` here.
311        let mut delta = self.radius.min(self.max_radius);
312
313        // Initial Riemannian gradient norm, captured on the first iteration and
314        // used as the shift-invariant scale in the relative stationarity test
315        // (see `relative_stationarity`).
316        let mut grad0_norm: Option<f64> = None;
317        let mut iterations = 0usize;
318
319        for _ in 0..self.max_iter {
320            let (f_curr, grad_e) = objective.value_gradient(x.view())?;
321            if !f_curr.is_finite() {
322                return Err(crate::manifold::GeometryError::InvalidPoint(
323                    "trust-region objective returned a non-finite value",
324                ));
325            }
326            iterations += 1;
327            // Raise the ambient Euclidean differential to the *Riemannian*
328            // gradient through the manifold metric. Merely projecting onto the
329            // tangent space is the Riemannian gradient only for the embedded
330            // (identity) metric; for a genuine metric (affine-invariant SPD,
331            // canonical Stiefel) it is the wrong direction, making the model
332            // linear term `g_x(grad, η)` not the differential `Df_x[η]` and the
333            // step not first-order correct (issue #955).
334            let grad = manifold.riemannian_gradient(x.view(), grad_e.view())?;
335            let grad_norm = g_norm(manifold, x.view(), grad.view())?;
336            // Shift-invariant (relative) stationarity test. Comparing the bare
337            // gradient norm to a fixed absolute `grad_tol` is mis-calibrated for
338            // objectives whose natural scale is large — e.g. the *profiled*
339            // Gaussian REML latent objective, whose `n·log σ̂²` term leaves
340            // `‖grad‖` at an O(n) magnitude even at a genuine stationary point
341            // near interpolation (issue #879). We instead test the dimensionless
342            // ratio `‖grad_k‖_g / max(‖grad_0‖_g, 1)`, where `‖grad_0‖_g` is the
343            // gradient norm at the initial iterate. It carries the same
344            // *multiplicative* scale the objective and its gradient share but is
345            // invariant under an additive shift `f → f + C` (unlike `max(|f|,1)`,
346            // which a large constant inflates into a false convergence, #954),
347            // and reduces to the absolute test on a unit-scale objective.
348            let grad0 = *grad0_norm.get_or_insert(grad_norm);
349            if relative_stationarity(grad_norm, grad0) <= self.grad_tol {
350                break;
351            }
352
353            // Solve the trust-region subproblem in T_xM.
354            let (step, predicted_reduction, hit_boundary) =
355                self.solve_subproblem(manifold, objective, x.view(), grad.view(), delta)?;
356
357            // A non-positive predicted reduction means the model offers no
358            // descent (e.g. a vanishing step); shrink and retry from the same
359            // point rather than dividing by ~0 in ρ.
360            if !(predicted_reduction > 0.0) {
361                delta *= policy.shrink_factor;
362                if delta <= self.grad_tol * self.grad_tol {
363                    break;
364                }
365                continue;
366            }
367
368            let trial_x = manifold.retract(x.view(), step.view())?;
369            let f_trial = objective.value_gradient(trial_x.view())?.0;
370            let actual_reduction = f_curr - f_trial;
371            // The step's length in the manifold metric — the same norm
372            // `hit_boundary` was decided in. `classic` sets no rejection
373            // step cap so the policy does not currently consult it, but
374            // handing it a placeholder would make the call a lie the day
375            // that changes.
376            let step_norm = g_inner(manifold, x.view(), step.view(), step.view())?
377                .max(0.0)
378                .sqrt();
379            // A non-finite trial value reaches the policy as a non-finite
380            // `actual_reduction`, which cannot clear `rho > eta_accept`, so
381            // the explicit `f_trial.is_finite()` conjunct the hand-rolled
382            // version carried is subsumed rather than dropped.
383            let tr = policy.update(
384                delta,
385                step_norm,
386                hit_boundary,
387                actual_reduction,
388                predicted_reduction,
389                f_curr,
390            );
391            delta = tr.new_radius;
392
393            // Accept only sufficiently-good steps; otherwise keep x (the next
394            // iteration recomputes f and the gradient at the retained point).
395            if tr.accepted {
396                x = trial_x;
397            }
398        }
399        // Returning a point is a mathematical claim: it must satisfy the same
400        // first-order certificate that controls the loop. Budget exhaustion,
401        // a collapsed radius, or a failed model step is not success merely
402        // because the last iterate is finite.
403        let (f_final, grad_e_final) = objective.value_gradient(x.view())?;
404        if !f_final.is_finite() {
405            return Err(crate::manifold::GeometryError::InvalidPoint(
406                "trust-region objective returned a non-finite terminal value",
407            ));
408        }
409        let grad_final = manifold.riemannian_gradient(x.view(), grad_e_final.view())?;
410        let grad_final_norm = g_norm(manifold, x.view(), grad_final.view())?;
411        let grad0 = grad0_norm.unwrap_or(grad_final_norm);
412        let residual = relative_stationarity(grad_final_norm, grad0);
413        Ok(TrustRegionTermination {
414            point: x,
415            iterations,
416            residual,
417            tolerance: self.grad_tol,
418        })
419    }
420
421    /// Solve `min_{‖η‖_g ≤ Δ} m(η)` and return `(η, m(0) − m(η), hit_boundary)`.
422    ///
423    /// Uses Steihaug truncated-CG when the objective provides Hessian–vector
424    /// products *and* the manifold's `retract` is at least a second-order
425    /// retraction ([`RiemannianManifold::retraction_is_second_order`]). When the
426    /// retraction is only first-order the Riemannian-Hessian quadratic term is
427    /// not the second derivative of `f∘R_x`, so scoring it would corrupt ρ
428    /// (issue #956); we then take the Cauchy point, whose linear model is
429    /// first-order correct along any retraction (as we also do when no curvature
430    /// is available).
431    fn solve_subproblem(
432        &self,
433        manifold: &dyn RiemannianManifold,
434        objective: &mut dyn RiemannianObjective,
435        x: ArrayView1<'_, f64>,
436        grad: ArrayView1<'_, f64>,
437        delta: f64,
438    ) -> GeometryResult<(Array1<f64>, f64, bool)> {
439        const BOUNDARY_FRAC: f64 = 0.9;
440
441        // Probe for curvature once: if the objective exposes no Hessian–vector
442        // product we take the Cauchy point.
443        let has_hessian = objective.hessian_vector_product(x, grad)?.is_some();
444
445        // The Riemannian-Hessian quadratic model `½ g_x(η, Hη)` is the correct
446        // second-order model of `f` along the trial path ONLY when that path is
447        // generated by the exponential map or another second-order retraction:
448        // for a first-order retraction `R_x` the pullback `f∘R_x` has a second
449        // derivative at `0` that is NOT the Riemannian Hessian, so scoring the
450        // curved model against `manifold.retract` corrupts ρ and the radius
451        // control (issue #956). The linear term `g_x(grad, η) = Df_x[η]` is
452        // retraction-independent, so the curvature-free Cauchy model stays
453        // first-order correct along ANY retraction. We therefore use the curved
454        // Steihaug truncated-CG step only when the objective supplies curvature
455        // AND the manifold's retraction is (at least) second-order; otherwise we
456        // take the Cauchy point — never asserting a second-order model the
457        // retraction cannot honor.
458        if !has_hessian || !manifold.retraction_is_second_order() {
459            return self.cauchy_point(manifold, x, grad, delta);
460        }
461
462        // --- Steihaug truncated-CG on the metric inner product. ---
463        // Solve min m(η) = g_x(grad, η) + ½ g_x(η, Hη) within ‖η‖_g ≤ Δ.
464        let n = grad.len();
465        let mut z = Array1::<f64>::zeros(n); // current iterate η
466        let mut r = grad.to_owned(); // residual = grad + Hz (z=0 ⇒ grad)
467        let mut p = -&r; // search direction
468        let r0_norm = g_norm(manifold, x, r.view())?;
469        let tol = (STEIHAUG_CG_FORCING_FACTOR * r0_norm).min(r0_norm * r0_norm);
470
471        // model reduction tracker m(0) − m(z); m(0) = 0 here (constant dropped).
472        // m(z) = g(grad,z) + ½ g(z,Hz); we recompute it at the end for ρ.
473        let max_cg = 2 * n + 1;
474        for _ in 0..max_cg {
475            let hp = objective.hessian_vector_product(x, p.view())?.ok_or(
476                crate::manifold::GeometryError::Unsupported(
477                    "Hessian–vector product became unavailable mid-subproblem",
478                ),
479            )?;
480            let php = g_inner(manifold, x, p.view(), hp.view())?;
481            if php <= 0.0 {
482                // Negative curvature: go to the boundary along p.
483                let (tau, _) = boundary_tau(manifold, x, z.view(), p.view(), delta)?;
484                let eta = &z + &(&p * tau);
485                let red = model_reduction(manifold, objective, x, grad, eta.view())?;
486                return Ok((eta, red, true));
487            }
488            let rr = g_inner(manifold, x, r.view(), r.view())?;
489            let alpha = rr / php;
490            let z_next = &z + &(&p * alpha);
491            if g_norm(manifold, x, z_next.view())? >= delta {
492                // Trust-region boundary crossed: step to it.
493                let (tau, _) = boundary_tau(manifold, x, z.view(), p.view(), delta)?;
494                let eta = &z + &(&p * tau);
495                let red = model_reduction(manifold, objective, x, grad, eta.view())?;
496                return Ok((eta, red, true));
497            }
498            z = z_next;
499            let r_next = &r + &(&hp * alpha);
500            let r_next_norm = g_norm(manifold, x, r_next.view())?;
501            if r_next_norm <= tol {
502                let red = model_reduction(manifold, objective, x, grad, z.view())?;
503                let hit = g_norm(manifold, x, z.view())? >= BOUNDARY_FRAC * delta;
504                return Ok((z, red, hit));
505            }
506            let rr_next = g_inner(manifold, x, r_next.view(), r_next.view())?;
507            let beta = rr_next / rr;
508            p = &(-&r_next) + &(&p * beta);
509            r = r_next;
510        }
511        let red = model_reduction(manifold, objective, x, grad, z.view())?;
512        let hit = g_norm(manifold, x, z.view())? >= BOUNDARY_FRAC * delta;
513        Ok((z, red, hit))
514    }
515
516    /// Cauchy point: the exact minimizer of the model along the steepest-descent
517    /// direction `−grad` within the trust region. With no curvature available
518    /// the model is the decreasing linear `m(τ·(−grad)) = −τ‖grad‖²_g`, whose
519    /// constrained minimizer sits on the boundary `τ = Δ / ‖grad‖_g`, giving a
520    /// predicted reduction `Δ·‖grad‖_g`.
521    fn cauchy_point(
522        &self,
523        manifold: &dyn RiemannianManifold,
524        x: ArrayView1<'_, f64>,
525        grad: ArrayView1<'_, f64>,
526        delta: f64,
527    ) -> GeometryResult<(Array1<f64>, f64, bool)> {
528        let grad_norm = g_norm(manifold, x, grad.view())?;
529        if grad_norm <= 0.0 {
530            return Ok((Array1::<f64>::zeros(grad.len()), 0.0, false));
531        }
532        let tau = delta / grad_norm;
533        let step = &grad.to_owned() * (-tau);
534        // Predicted reduction of the linear model m(0) − m(η) = τ‖grad‖²_g.
535        let predicted = tau * grad_norm * grad_norm;
536        Ok((step, predicted, true))
537    }
538}
539
540/// Largest `τ ≥ 0` with `‖z + τ p‖_g = Δ`, solving the quadratic
541/// `‖p‖²_g τ² + 2 g(z,p) τ + (‖z‖²_g − Δ²) = 0`. Returns `(τ, ‖z + τp‖_g)`.
542fn boundary_tau(
543    manifold: &dyn RiemannianManifold,
544    x: ArrayView1<'_, f64>,
545    z: ArrayView1<'_, f64>,
546    p: ArrayView1<'_, f64>,
547    delta: f64,
548) -> GeometryResult<(f64, f64)> {
549    let pp = g_inner(manifold, x, p, p)?;
550    let zp = g_inner(manifold, x, z, p)?;
551    let zz = g_inner(manifold, x, z, z)?;
552    if pp <= 0.0 {
553        return Ok((0.0, zz.max(0.0).sqrt()));
554    }
555    let c = zz - delta * delta;
556    let disc = (zp * zp - pp * c).max(0.0);
557    let tau = (-zp + disc.sqrt()) / pp;
558    let tau = tau.max(0.0);
559    Ok((tau, delta))
560}
561
562/// Model reduction `m(0) − m(η) = −g(grad, η) − ½ g(η, Hη)`.
563fn model_reduction(
564    manifold: &dyn RiemannianManifold,
565    objective: &mut dyn RiemannianObjective,
566    x: ArrayView1<'_, f64>,
567    grad: ArrayView1<'_, f64>,
568    eta: ArrayView1<'_, f64>,
569) -> GeometryResult<f64> {
570    let lin = g_inner(manifold, x, grad, eta)?;
571    let heta = objective.hessian_vector_product(x, eta)?.ok_or(
572        crate::manifold::GeometryError::Unsupported(
573            "Hessian–vector product unavailable while scoring the model",
574        ),
575    )?;
576    let quad = g_inner(manifold, x, eta, heta.view())?;
577    Ok(-lin - 0.5 * quad)
578}
579
580#[derive(Debug, Clone, PartialEq)]
581pub struct RiemannianLBFGS {
582    pub history: usize,
583    pub step_size: f64,
584    pub max_iter: usize,
585    pub grad_tol: f64,
586}
587
588impl Default for RiemannianLBFGS {
589    fn default() -> Self {
590        Self {
591            history: 10,
592            step_size: 1.0,
593            max_iter: 100,
594            grad_tol: 1.0e-8,
595        }
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use crate::EuclideanManifold;
603    use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
604
605    struct IndefiniteLine;
606
607    impl RiemannianManifold for IndefiniteLine {
608        fn dim(&self) -> usize {
609            1
610        }
611
612        fn tangent_basis(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>> {
613            assert_eq!(point.len(), 1, "IndefiniteLine points are one-dimensional");
614            Ok(Array2::eye(1))
615        }
616
617        fn exp_map(
618            &self,
619            point: ArrayView1<'_, f64>,
620            tangent_vec: ArrayView1<'_, f64>,
621        ) -> GeometryResult<Array1<f64>> {
622            Ok(&point.to_owned() + &tangent_vec)
623        }
624
625        fn log_map(
626            &self,
627            p_from: ArrayView1<'_, f64>,
628            p_to: ArrayView1<'_, f64>,
629        ) -> GeometryResult<Array1<f64>> {
630            Ok(&p_to.to_owned() - &p_from)
631        }
632
633        fn parallel_transport(
634            &self,
635            point_along: ArrayView2<'_, f64>,
636            vec: ArrayView1<'_, f64>,
637        ) -> GeometryResult<Array1<f64>> {
638            assert_eq!(
639                point_along.ncols(),
640                1,
641                "IndefiniteLine transport paths are one-dimensional"
642            );
643            assert_eq!(vec.len(), 1, "IndefiniteLine tangents are one-dimensional");
644            Ok(vec.to_owned())
645        }
646
647        fn metric_tensor(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>> {
648            assert_eq!(point.len(), 1, "IndefiniteLine points are one-dimensional");
649            Ok(ndarray::array![[-1.0]])
650        }
651
652        fn sectional_curvature(
653            &self,
654            point: ArrayView1<'_, f64>,
655            tangent_pair: (ArrayView1<'_, f64>, ArrayView1<'_, f64>),
656        ) -> GeometryResult<f64> {
657            assert_eq!(point.len(), 1, "IndefiniteLine points are one-dimensional");
658            assert_eq!(
659                tangent_pair.0.len(),
660                1,
661                "IndefiniteLine tangents are one-dimensional"
662            );
663            assert_eq!(
664                tangent_pair.1.len(),
665                1,
666                "IndefiniteLine tangents are one-dimensional"
667            );
668            Ok(0.0)
669        }
670    }
671
672    /// Scalar objective `f(x) = x²` on the 1-D Euclidean line. Gradient `2x`,
673    /// Hessian `2`, exposed as an HVP so the trust region runs Steihaug-CG.
674    struct Square;
675    impl RiemannianObjective for Square {
676        fn value_gradient(
677            &mut self,
678            point: ArrayView1<'_, f64>,
679        ) -> GeometryResult<(f64, Array1<f64>)> {
680            let x = point[0];
681            Ok((x * x, Array1::from_vec(vec![2.0 * x])))
682        }
683        fn hessian_vector_product(
684            &mut self,
685            point: ArrayView1<'_, f64>,
686            tangent: ArrayView1<'_, f64>,
687        ) -> GeometryResult<Option<Array1<f64>>> {
688            assert!(point.iter().all(|value| value.is_finite()));
689            check_len("hessian_vector_product tangent", tangent.len(), point.len())?;
690            Ok(Some(&tangent.to_owned() * 2.0))
691        }
692    }
693
694    /// Gradient-only variant of `f(x)=x²` (no HVP) to exercise the Cauchy-point
695    /// branch of the trust region.
696    struct SquareGradOnly;
697    impl RiemannianObjective for SquareGradOnly {
698        fn value_gradient(
699            &mut self,
700            point: ArrayView1<'_, f64>,
701        ) -> GeometryResult<(f64, Array1<f64>)> {
702            let x = point[0];
703            Ok((x * x, Array1::from_vec(vec![2.0 * x])))
704        }
705    }
706
707    /// General convex quadratic `f(x) = ½ xᵀ A x − bᵀ x` on Euclidean R^n with
708    /// SPD `A`; minimizer solves `A x = b`. Provides an exact HVP `A v`.
709    struct Quadratic {
710        a: ndarray::Array2<f64>,
711        b: Array1<f64>,
712    }
713    impl RiemannianObjective for Quadratic {
714        fn value_gradient(
715            &mut self,
716            point: ArrayView1<'_, f64>,
717        ) -> GeometryResult<(f64, Array1<f64>)> {
718            let ax = self.a.dot(&point.to_owned());
719            let val = 0.5 * point.dot(&ax) - self.b.dot(&point.to_owned());
720            let grad = &ax - &self.b;
721            Ok((val, grad))
722        }
723        fn hessian_vector_product(
724            &mut self,
725            point: ArrayView1<'_, f64>,
726            tangent: ArrayView1<'_, f64>,
727        ) -> GeometryResult<Option<Array1<f64>>> {
728            assert!(point.iter().all(|value| value.is_finite()));
729            check_len("hessian_vector_product tangent", tangent.len(), point.len())?;
730            Ok(Some(self.a.dot(&tangent.to_owned())))
731        }
732    }
733
734    /// (#615 counterexample) A correct trust region on `f(x)=x²` from `x₀=0.1`
735    /// with `Δ=1` must CONVERGE to 0 (not oscillate), monotonically driving `f`
736    /// down — never increasing it on an accepted iterate.
737    #[test]
738    fn trust_region_converges_on_square_steihaug() {
739        let manifold = EuclideanManifold::new(1);
740        let tr = RiemannianTrustRegion {
741            radius: 1.0,
742            max_radius: 1.0e6,
743            max_iter: 100,
744            grad_tol: 1.0e-12,
745        };
746        let mut obj = Square;
747        let x0 = Array1::from_vec(vec![0.1]);
748        let x = tr
749            .minimize(&manifold, &mut obj, x0.view())
750            .expect("TR runs");
751        assert!(
752            x[0].abs() < 1.0e-6,
753            "trust region must converge to 0, got {}",
754            x[0]
755        );
756    }
757
758    /// The trust region must never increase `f` across accepted iterates. We
759    /// check the monotone-descent invariant directly by stepping the public
760    /// `minimize` from a sequence of decreasing budgets and confirming the
761    /// returned value is below the start value, and that from `x₀=0.1` it does
762    /// not return a point with larger `|x|`.
763    #[test]
764    fn trust_region_never_increases_objective() {
765        let manifold = EuclideanManifold::new(1);
766        let tr = RiemannianTrustRegion {
767            radius: 1.0,
768            max_radius: 1.0e6,
769            max_iter: 1,
770            grad_tol: 1.0e-12,
771        };
772        let mut obj = Square;
773        // A single TR iteration from 0.1: with exact Hessian the Newton step
774        // lands at the minimum (inside Δ=1), ρ=1, so it must be accepted and f
775        // must strictly decrease.
776        let x0 = Array1::from_vec(vec![0.1]);
777        let f0 = obj.value_gradient(x0.view()).unwrap().0;
778        let x1 = tr
779            .minimize(&manifold, &mut obj, x0.view())
780            .expect("TR runs");
781        let f1 = obj.value_gradient(x1.view()).unwrap().0;
782        assert!(f1 <= f0, "objective increased: {f0} -> {f1}");
783        assert!(x1[0].abs() <= x0[0].abs() + 1e-15, "moved away from min");
784    }
785
786    /// Cauchy-point branch (no HVP) must still be a real trust-region method:
787    /// from `x₀=0.1`, `Δ=1` on `f(x)=x²` it converges toward 0 and never
788    /// oscillates upward in `f`.
789    #[test]
790    fn trust_region_cauchy_point_converges() {
791        let manifold = EuclideanManifold::new(1);
792        let tr = RiemannianTrustRegion {
793            radius: 1.0,
794            max_radius: 1.0e6,
795            max_iter: 500,
796            grad_tol: 1.0e-12,
797        };
798        let mut obj = SquareGradOnly;
799        let x0 = Array1::from_vec(vec![0.1]);
800        let x = tr
801            .minimize(&manifold, &mut obj, x0.view())
802            .expect("TR runs");
803        assert!(
804            x[0].abs() < 1.0e-6,
805            "Cauchy-point trust region must converge to 0, got {}",
806            x[0]
807        );
808    }
809
810    /// Steihaug-CG trust region on a 3-D SPD quadratic must reach the exact
811    /// minimizer `A⁻¹ b`.
812    #[test]
813    fn trust_region_solves_spd_quadratic() {
814        let manifold = EuclideanManifold::new(3);
815        let a = ndarray::array![[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0],];
816        let b = Array1::from_vec(vec![1.0, 2.0, -1.0]);
817        // Reference solution A x = b.
818        let x_ref = crate::manifold::inverse(&a).unwrap().dot(&b);
819        let mut obj = Quadratic { a, b };
820        let tr = RiemannianTrustRegion {
821            radius: 1.0,
822            max_radius: 1.0e6,
823            max_iter: 200,
824            grad_tol: 1.0e-12,
825        };
826        let x0 = Array1::from_vec(vec![0.0, 0.0, 0.0]);
827        let x = tr
828            .minimize(&manifold, &mut obj, x0.view())
829            .expect("TR runs");
830        for i in 0..3 {
831            assert!(
832                (x[i] - x_ref[i]).abs() < 1.0e-6,
833                "component {i}: got {}, want {}",
834                x[i],
835                x_ref[i]
836            );
837        }
838    }
839
840    #[test]
841    fn nonfinite_gradient_cannot_be_misread_as_zero_norm() {
842        struct NanGradient;
843        impl RiemannianObjective for NanGradient {
844            fn value_gradient(
845                &mut self,
846                point: ArrayView1<'_, f64>,
847            ) -> GeometryResult<(f64, Array1<f64>)> {
848                assert_eq!(point.len(), 1, "NanGradient is one-dimensional");
849                Ok((0.0, Array1::from_vec(vec![f64::NAN])))
850            }
851        }
852
853        let manifold = EuclideanManifold::new(1);
854        let x0 = Array1::zeros(1);
855        let mut objective = NanGradient;
856        let error = RiemannianTrustRegion::default()
857            .minimize(&manifold, &mut objective, x0.view())
858            .expect_err("NaN gradient must never certify stationarity");
859        assert!(matches!(
860            error,
861            crate::manifold::GeometryError::NonConvergence { residual, .. }
862                if residual.is_infinite()
863        ));
864    }
865
866    #[test]
867    fn overflowed_metric_error_bound_cannot_certify_zero_norm() {
868        // The signed quadratic cancels to zero in this order, while the sum of
869        // absolute terms overflows. Treating an infinite backward-error band
870        // as a valid tolerance would turn this indefinite quadratic into a
871        // zero norm and falsely certify stationarity.
872        let vector = ndarray::array![1.0, 1.0, 1.0, 1.0];
873        let metric_product = ndarray::array![9.0e307, -9.0e307, 9.0e307, -9.0e307];
874        let error = metric_norm_from_product(vector.view(), metric_product.view())
875            .expect_err("an overflowed norm error bound must be rejected");
876        assert!(matches!(
877            error,
878            crate::manifold::GeometryError::InvalidPoint(
879                "Riemannian metric norm error bound overflowed"
880            )
881        ));
882    }
883
884    #[test]
885    fn indefinite_metric_cannot_be_clamped_into_false_stationarity() {
886        let manifold = IndefiniteLine;
887        let mut objective = Square;
888        let x0 = Array1::from_vec(vec![1.0]);
889        let error = RiemannianTrustRegion::default()
890            .minimize(&manifold, &mut objective, x0.view())
891            .expect_err("an indefinite metric is not a Riemannian norm");
892        assert!(matches!(
893            error,
894            crate::manifold::GeometryError::InvalidPoint(
895                "Riemannian metric produced a negative squared norm"
896            )
897        ));
898    }
899}