Skip to main content

gam_geometry/
optimizer.rs

1use gam_linalg::roundoff::accumulation_band;
2use ndarray::{Array1, ArrayView1};
3use opt::{
4    BacktrackConfig, ExpandConfig, TrustRegionPolicy, bidirectional_line_search, constants,
5};
6
7use crate::manifold::{GeometryResult, RiemannianManifold, check_len, quad_form};
8
9/// Linear factor of the Steihaug truncated-CG forcing sequence: the inner CG
10/// solve is terminated once the residual drops to `min(η·‖r₀‖, ‖r₀‖²)`. The
11/// quadratic `‖r₀‖²` term gives the super-linear convergence of an inexact
12/// Newton step near the optimum, while `η·‖r₀‖` caps wasted inner work far from
13/// it (Nocedal & Wright, *Numerical Optimization*, §7.1, eq. 7.3).
14const STEIHAUG_CG_FORCING_FACTOR: f64 = 1.0e-2;
15
16pub trait RiemannianObjective {
17    fn value_gradient(&mut self, point: ArrayView1<'_, f64>) -> GeometryResult<(f64, Array1<f64>)>;
18
19    /// Riemannian Hessian–vector product `H(x)·v` for a tangent direction `v`
20    /// at `point`, returned in the same ambient/tangent coordinates as the
21    /// gradient.
22    ///
23    /// This is what upgrades the trust-region subproblem from a Cauchy-point
24    /// step (the exact minimizer of the *linear* model along the steepest
25    /// descent direction) to a Steihaug truncated-CG step that exploits real
26    /// curvature. An objective that exposes no second-order information returns
27    /// `None` (the default), and the trust region transparently falls back to
28    /// the Cauchy point — never to plain clipped steepest descent, which has no
29    /// model, no predicted/actual reduction ratio, and no accept/reject.
30    ///
31    /// The Riemannian-Hessian quadratic model the trust region builds from this
32    /// product is a valid second-order model of `f` only along a (≥)second-order
33    /// retraction (the exponential map, or any retraction with
34    /// [`RiemannianManifold::retraction_is_second_order`] `== true`). On a
35    /// manifold whose `retract` is only FIRST-order (e.g. the Stiefel/Grassmann
36    /// QR retraction) the second derivative of the pullback `f∘R_x` is not the
37    /// Riemannian Hessian, so the trust region ignores this curvature and uses
38    /// the first-order-correct Cauchy model instead (issue #956).
39    fn hessian_vector_product(
40        &mut self,
41        point: ArrayView1<'_, f64>,
42        tangent: ArrayView1<'_, f64>,
43    ) -> GeometryResult<Option<Array1<f64>>> {
44        // Validate the shapes the contract requires (a tangent at `point`), then
45        // report "no curvature available" so the trust region selects the
46        // Cauchy point. We never fabricate a Hessian here.
47        check_len("hessian_vector_product tangent", tangent.len(), point.len())?;
48        Ok(None)
49    }
50}
51
52/// Metric inner product `g_x(a, b) = aᵀ G(x) b` using the manifold metric
53/// tensor at `point`. For manifolds whose metric is the ambient identity
54/// (Euclidean, Sphere, Circle, Torus, …) this reduces to the Euclidean dot
55/// product; for a genuine Riemannian metric (e.g. the affine-invariant SPD
56/// metric) it evaluates the correct geometric inner product on the tangent
57/// space. Every norm and inner product in both optimizers below routes through
58/// this so the algorithms are metric-correct on curved manifolds.
59fn g_inner(
60    manifold: &dyn RiemannianManifold,
61    point: ArrayView1<'_, f64>,
62    a: ArrayView1<'_, f64>,
63    b: ArrayView1<'_, f64>,
64) -> GeometryResult<f64> {
65    let g = manifold.metric_tensor(point)?;
66    Ok(quad_form(g.view(), a, b))
67}
68
69fn g_norm(
70    manifold: &dyn RiemannianManifold,
71    point: ArrayView1<'_, f64>,
72    a: ArrayView1<'_, f64>,
73) -> GeometryResult<f64> {
74    let metric = manifold.metric_tensor(point)?;
75    let metric_times_a = gam_linalg::faer_ndarray::fast_av(&metric.view(), &a);
76    metric_norm_from_product(a, metric_times_a.view())
77}
78
79/// Certify `sqrt(a^T G a)` once the metric product `G a` is available.
80///
81/// The absolute accumulation is part of the backward-error certificate.  It
82/// must itself remain finite: an infinite error scale would make every finite
83/// negative quadratic look like harmless roundoff and could certify a
84/// non-zero vector as having zero norm under an indefinite metric.
85fn metric_norm_from_product(
86    a: ArrayView1<'_, f64>,
87    metric_times_a: ArrayView1<'_, f64>,
88) -> GeometryResult<f64> {
89    check_len("metric norm product", metric_times_a.len(), a.len())?;
90    let mut squared_norm = 0.0_f64;
91    let mut absolute_sum = 0.0_f64;
92    for (&left, &right) in a.iter().zip(metric_times_a.iter()) {
93        let term = left * right;
94        squared_norm += term;
95        absolute_sum += term.abs();
96    }
97    if !squared_norm.is_finite() {
98        return Ok(f64::INFINITY);
99    }
100    if !absolute_sum.is_finite() {
101        return Err(crate::manifold::GeometryError::InvalidPoint(
102            "Riemannian metric norm error bound overflowed",
103        ));
104    }
105    // A Riemannian metric is positive definite. Permit only the backward-error
106    // band of the final dot product; clamping a materially negative quadratic
107    // to zero would falsely turn an indefinite metric into a stationary point.
108    // That band is Wilkinson's for this exact accumulation — both of its inputs,
109    // the term count and the absolute sum, are the loop's own state — so it is
110    // computed rather than guessed. A fixed multiple of EPSILON would be too
111    // tight on a high-dimensional tangent space, rejecting metrics whose
112    // squared norm is zero in exact arithmetic, and needlessly loose on a
113    // low-dimensional one.
114    let negative_roundoff = accumulation_band(a.len(), absolute_sum);
115    if squared_norm < -negative_roundoff {
116        return Err(crate::manifold::GeometryError::InvalidPoint(
117            "Riemannian metric produced a negative squared norm",
118        ));
119    }
120    Ok(squared_norm.max(0.0).sqrt())
121}
122
123/// Shift-invariant relative-gradient stationarity measure
124/// `‖grad_k‖_g / max(‖grad_0‖_g, 1)`, comparing the current Riemannian gradient
125/// norm to the gradient norm at the INITIAL iterate. The initial gradient norm
126/// carries the same *multiplicative* scale the objective and its gradient share
127/// (`f → c·f` ⇒ `grad → c·grad`), so a fixed `grad_tol` still reads as a
128/// *relative* tolerance — but, unlike dividing by `max(|f|, 1)`, `‖grad_0‖` is
129/// invariant under an additive shift `f → f + C`, which leaves the minimizers,
130/// gradient, trust-region model reduction, Armijo slope, and accepted path all
131/// unchanged. Dividing by `|f|` was non-invariant: a large additive constant
132/// inflates the denominator and can falsely certify convergence at a
133/// non-stationary iterate (e.g. `f̃(x) = C + x²` at `x = 1` with `C > 2/τ − 1`),
134/// issue #954. The `max(·, 1)` floor reduces this to the absolute test
135/// `‖grad_k‖ ≤ grad_tol` on a unit-scale objective and preserves the
136/// O(n)-gradient calibration of the profiled REML latent objective (whose
137/// `‖grad_0‖` is itself O(n), issue #879). The non-intrinsic `‖x‖_typ` factor is
138/// dropped: ambient iterate magnitude is not coordinate/chart invariant on a
139/// manifold, so it does not belong in a Riemannian stationarity test. A
140/// non-finite gradient maps to `+∞` so a blown-up iterate is never stationary.
141fn relative_stationarity(grad_norm: f64, grad0_norm: f64) -> f64 {
142    if !grad_norm.is_finite() || !grad0_norm.is_finite() {
143        return f64::INFINITY;
144    }
145    grad_norm / grad0_norm.max(1.0)
146}
147
148/// The context string of the trust-region first-order certificate, shared by the
149/// refusal in [`RiemannianTrustRegion::minimize`] and by callers that re-report
150/// the same verdict from [`TrustRegionTermination`].
151pub const TRUST_REGION_RELATIVE_GRADIENT_CONTEXT: &str =
152    "Riemannian trust-region optimization (relative gradient norm)";
153
154/// Terminal state of a trust-region run: the iterate reached, and the numbers the
155/// first-order certificate was decided against.
156#[derive(Clone, Debug)]
157pub struct TrustRegionTermination {
158    /// The last iterate. Present whether or not the certificate holds — this is
159    /// the work a budget-exhausted run has to hand back.
160    pub point: Array1<f64>,
161    /// Iterations actually executed (zero when the budget was zero).
162    pub iterations: usize,
163    /// Relative stationarity `‖g_final‖ / max(‖g_0‖, 1)` at `point`.
164    pub residual: f64,
165    /// The bound `residual` was compared against.
166    pub tolerance: f64,
167}
168
169impl TrustRegionTermination {
170    /// Whether `point` satisfies the first-order certificate that controls the
171    /// loop. This is the same test `minimize` applies before returning a point.
172    pub fn certifies(&self) -> bool {
173        self.residual <= self.tolerance
174    }
175}
176
177#[derive(Debug, Clone, PartialEq)]
178pub struct RiemannianTrustRegion {
179    /// Initial trust-region radius Δ₀.
180    pub radius: f64,
181    /// Hard cap Δmax on the radius across all iterations.
182    pub max_radius: f64,
183    pub max_iter: usize,
184    pub grad_tol: f64,
185}
186
187impl Default for RiemannianTrustRegion {
188    fn default() -> Self {
189        Self {
190            radius: 1.0,
191            max_radius: 1.0e6,
192            max_iter: 64,
193            grad_tol: 1.0e-8,
194        }
195    }
196}
197
198impl RiemannianTrustRegion {
199    /// A genuine Riemannian trust-region method.
200    ///
201    /// At each iterate `x` we build the quadratic model in the tangent space
202    /// `T_xM`,
203    ///
204    /// ```text
205    ///   m(η) = f(x) + g_x(grad, η) + ½ g_x(η, Hη),
206    /// ```
207    ///
208    /// where `g_x(·,·)` is the manifold metric inner product and `H` is the
209    /// Riemannian Hessian (accessed only through Hessian–vector products). The
210    /// step is the (approximate) solution of the trust-region subproblem
211    ///
212    /// ```text
213    ///   min_{η ∈ T_xM, ‖η‖_g ≤ Δ}  m(η).
214    /// ```
215    ///
216    /// When the objective supplies Hessian–vector products AND the manifold's
217    /// `retract` is at least a second-order retraction
218    /// ([`RiemannianManifold::retraction_is_second_order`]) we solve the
219    /// subproblem with the Steihaug truncated-CG method (stopping at negative
220    /// curvature or the trust-region boundary). Otherwise — no curvature, or a
221    /// first-order retraction whose pullback second derivative is not the
222    /// Riemannian Hessian (issue #956) — we fall back to the Cauchy point: the
223    /// exact minimizer of the model along the steepest-descent direction within
224    /// the trust region (with curvature taken from the model where available,
225    /// and the boundary point of the decreasing linear model otherwise). The
226    /// linear term `Df_x[η]` is retraction-independent, so the Cauchy model
227    /// keeps ρ and the radius control valid along any retraction. Either way
228    /// this is a real model-based step — not clipped descent.
229    ///
230    /// We then form the ratio of actual to predicted reduction
231    ///
232    /// ```text
233    ///   ρ = (f(x) − f(x⁺)) / (m(0) − m(η)),
234    /// ```
235    ///
236    /// accept the step only when `ρ > η₁`, and adapt Δ: shrink on a poor ratio,
237    /// expand on an excellent ratio that reaches the boundary, otherwise hold.
238    /// Only accepted steps are retracted onto the manifold.
239    pub fn minimize(
240        &self,
241        manifold: &dyn RiemannianManifold,
242        objective: &mut dyn RiemannianObjective,
243        initial: ArrayView1<'_, f64>,
244    ) -> GeometryResult<Array1<f64>> {
245        let termination = self.minimize_reporting_termination(manifold, objective, initial)?;
246        if termination.certifies() {
247            Ok(termination.point)
248        } else {
249            Err(crate::manifold::GeometryError::NonConvergence {
250                context: TRUST_REGION_RELATIVE_GRADIENT_CONTEXT,
251                iterations: termination.iterations,
252                residual: termination.residual,
253                tolerance: termination.tolerance,
254            })
255        }
256    }
257
258    /// As [`Self::minimize`], but reporting the terminal iterate alongside the
259    /// first-order verdict instead of discarding it.
260    ///
261    /// `minimize` returns `Err(NonConvergence)` when the terminal point fails the
262    /// relative-gradient certificate, and that error carries the residual but not
263    /// the POINT. A caller whose contract is checkpoint/resume cannot be served
264    /// by it: the work done before the budget ran out is exactly the iterate, and
265    /// with only a residual there is nothing to resume from. Genuine failures —
266    /// a non-finite value, an invalid radius, an objective or manifold error —
267    /// are still `Err` here; only the first-order test is demoted from an error
268    /// to a reported verdict, so `minimize` above reconstructs its own behavior
269    /// exactly and every existing caller is unaffected.
270    pub fn minimize_reporting_termination(
271        &self,
272        manifold: &dyn RiemannianManifold,
273        objective: &mut dyn RiemannianObjective,
274        initial: ArrayView1<'_, f64>,
275    ) -> GeometryResult<TrustRegionTermination> {
276        // Trust-region acceptance and radius control come from `opt`, not
277        // from constants re-declared here. SPEC-22 puts general outer
278        // optimizer work in `opt`, and a trust-region rho-controller is
279        // exactly that: this loop's five constants were bit-for-bit the
280        // ones `TrustRegionPolicy::classic` already ships (accept 0.1,
281        // shrink below 0.25, expand above 0.75 at the boundary, x0.25,
282        // x2.0), so keeping a private copy bought nothing and gave the
283        // radius rule two places to drift apart.
284        let policy = TrustRegionPolicy::classic(self.max_radius);
285        let mut x = initial.to_owned();
286        let d = manifold.ambient_dim();
287        check_len("trust-region initial point", x.len(), d)?;
288        if !(self.radius.is_finite() && self.radius > 0.0) {
289            return Err(crate::manifold::GeometryError::InvalidPoint(
290                "trust-region radius must be finite and positive",
291            ));
292        }
293        if !(self.max_radius.is_finite() && self.max_radius > 0.0) {
294            return Err(crate::manifold::GeometryError::InvalidPoint(
295                "trust-region maximum radius must be finite and positive",
296            ));
297        }
298        if !(self.grad_tol.is_finite() && self.grad_tol >= 0.0) {
299            return Err(crate::manifold::GeometryError::InvalidPoint(
300                "trust-region gradient tolerance must be finite and non-negative",
301            ));
302        }
303
304        // Establish the trust-region invariant `0 < Δ_k ≤ Δmax` *before* the
305        // first step, not just on later expansions. The expansion rule below
306        // caps via `min(·, max_radius)` and contraction only shrinks, so once
307        // `0 < Δ₀ ≤ Δmax` holds we have `0 < Δ_k ≤ Δmax` for all `k` by
308        // induction; every subproblem then obeys `‖η_k‖_g ≤ Δ_k ≤ Δmax`,
309        // restoring `max_radius` as the documented hard cap. A configured
310        // `radius > max_radius` (or a non-finite `radius`) would otherwise let
311        // the very first Cauchy/Steihaug step overshoot the advertised maximum,
312        // so we clamp the initial radius into `(0, max_radius]` here.
313        let mut delta = self.radius.min(self.max_radius);
314
315        // Initial Riemannian gradient norm, captured on the first iteration and
316        // used as the shift-invariant scale in the relative stationarity test
317        // (see `relative_stationarity`).
318        let mut grad0_norm: Option<f64> = None;
319        let mut iterations = 0usize;
320
321        for _ in 0..self.max_iter {
322            let (f_curr, grad_e) = objective.value_gradient(x.view())?;
323            if !f_curr.is_finite() {
324                return Err(crate::manifold::GeometryError::InvalidPoint(
325                    "trust-region objective returned a non-finite value",
326                ));
327            }
328            iterations += 1;
329            // Raise the ambient Euclidean differential to the *Riemannian*
330            // gradient through the manifold metric. Merely projecting onto the
331            // tangent space is the Riemannian gradient only for the embedded
332            // (identity) metric; for a genuine metric (affine-invariant SPD,
333            // canonical Stiefel) it is the wrong direction, making the model
334            // linear term `g_x(grad, η)` not the differential `Df_x[η]` and the
335            // step not first-order correct (issue #955).
336            let grad = manifold.riemannian_gradient(x.view(), grad_e.view())?;
337            let grad_norm = g_norm(manifold, x.view(), grad.view())?;
338            // Shift-invariant (relative) stationarity test. Comparing the bare
339            // gradient norm to a fixed absolute `grad_tol` is mis-calibrated for
340            // objectives whose natural scale is large — e.g. the *profiled*
341            // Gaussian REML latent objective, whose `n·log σ̂²` term leaves
342            // `‖grad‖` at an O(n) magnitude even at a genuine stationary point
343            // near interpolation (issue #879). We instead test the dimensionless
344            // ratio `‖grad_k‖_g / max(‖grad_0‖_g, 1)`, where `‖grad_0‖_g` is the
345            // gradient norm at the initial iterate. It carries the same
346            // *multiplicative* scale the objective and its gradient share but is
347            // invariant under an additive shift `f → f + C` (unlike `max(|f|,1)`,
348            // which a large constant inflates into a false convergence, #954),
349            // and reduces to the absolute test on a unit-scale objective.
350            let grad0 = *grad0_norm.get_or_insert(grad_norm);
351            if relative_stationarity(grad_norm, grad0) <= self.grad_tol {
352                break;
353            }
354
355            // Solve the trust-region subproblem in T_xM.
356            let (step, predicted_reduction, hit_boundary) =
357                self.solve_subproblem(manifold, objective, x.view(), grad.view(), delta)?;
358
359            // A non-positive predicted reduction means the model offers no
360            // descent (e.g. a vanishing step); shrink and retry from the same
361            // point rather than dividing by ~0 in ρ.
362            if !(predicted_reduction > 0.0) {
363                delta *= policy.shrink_factor;
364                if delta <= self.grad_tol * self.grad_tol {
365                    break;
366                }
367                continue;
368            }
369
370            let trial_x = manifold.retract(x.view(), step.view())?;
371            let f_trial = objective.value_gradient(trial_x.view())?.0;
372            let actual_reduction = f_curr - f_trial;
373            // The step's length in the manifold metric — the same norm
374            // `hit_boundary` was decided in. `classic` sets no rejection
375            // step cap so the policy does not currently consult it, but
376            // handing it a placeholder would make the call a lie the day
377            // that changes.
378            let step_norm = g_inner(manifold, x.view(), step.view(), step.view())?
379                .max(0.0)
380                .sqrt();
381            // A non-finite trial value reaches the policy as a non-finite
382            // `actual_reduction`, which cannot clear `rho > eta_accept`, so
383            // the explicit `f_trial.is_finite()` conjunct the hand-rolled
384            // version carried is subsumed rather than dropped.
385            let tr = policy.update(
386                delta,
387                step_norm,
388                hit_boundary,
389                actual_reduction,
390                predicted_reduction,
391                f_curr,
392            );
393            delta = tr.new_radius;
394
395            // Accept only sufficiently-good steps; otherwise keep x (the next
396            // iteration recomputes f and the gradient at the retained point).
397            if tr.accepted {
398                x = trial_x;
399            }
400        }
401        // Returning a point is a mathematical claim: it must satisfy the same
402        // first-order certificate that controls the loop. Budget exhaustion,
403        // a collapsed radius, or a failed model step is not success merely
404        // because the last iterate is finite.
405        let (f_final, grad_e_final) = objective.value_gradient(x.view())?;
406        if !f_final.is_finite() {
407            return Err(crate::manifold::GeometryError::InvalidPoint(
408                "trust-region objective returned a non-finite terminal value",
409            ));
410        }
411        let grad_final = manifold.riemannian_gradient(x.view(), grad_e_final.view())?;
412        let grad_final_norm = g_norm(manifold, x.view(), grad_final.view())?;
413        let grad0 = grad0_norm.unwrap_or(grad_final_norm);
414        let residual = relative_stationarity(grad_final_norm, grad0);
415        Ok(TrustRegionTermination {
416            point: x,
417            iterations,
418            residual,
419            tolerance: self.grad_tol,
420        })
421    }
422
423    /// Solve `min_{‖η‖_g ≤ Δ} m(η)` and return `(η, m(0) − m(η), hit_boundary)`.
424    ///
425    /// Uses Steihaug truncated-CG when the objective provides Hessian–vector
426    /// products *and* the manifold's `retract` is at least a second-order
427    /// retraction ([`RiemannianManifold::retraction_is_second_order`]). When the
428    /// retraction is only first-order the Riemannian-Hessian quadratic term is
429    /// not the second derivative of `f∘R_x`, so scoring it would corrupt ρ
430    /// (issue #956); we then take the Cauchy point, whose linear model is
431    /// first-order correct along any retraction (as we also do when no curvature
432    /// is available).
433    fn solve_subproblem(
434        &self,
435        manifold: &dyn RiemannianManifold,
436        objective: &mut dyn RiemannianObjective,
437        x: ArrayView1<'_, f64>,
438        grad: ArrayView1<'_, f64>,
439        delta: f64,
440    ) -> GeometryResult<(Array1<f64>, f64, bool)> {
441        const BOUNDARY_FRAC: f64 = 0.9;
442
443        // Probe for curvature once: if the objective exposes no Hessian–vector
444        // product we take the Cauchy point.
445        let has_hessian = objective.hessian_vector_product(x, grad)?.is_some();
446
447        // The Riemannian-Hessian quadratic model `½ g_x(η, Hη)` is the correct
448        // second-order model of `f` along the trial path ONLY when that path is
449        // generated by the exponential map or another second-order retraction:
450        // for a first-order retraction `R_x` the pullback `f∘R_x` has a second
451        // derivative at `0` that is NOT the Riemannian Hessian, so scoring the
452        // curved model against `manifold.retract` corrupts ρ and the radius
453        // control (issue #956). The linear term `g_x(grad, η) = Df_x[η]` is
454        // retraction-independent, so the curvature-free Cauchy model stays
455        // first-order correct along ANY retraction. We therefore use the curved
456        // Steihaug truncated-CG step only when the objective supplies curvature
457        // AND the manifold's retraction is (at least) second-order; otherwise we
458        // take the Cauchy point — never asserting a second-order model the
459        // retraction cannot honor.
460        if !has_hessian || !manifold.retraction_is_second_order() {
461            return self.cauchy_point(manifold, x, grad, delta);
462        }
463
464        // --- Steihaug truncated-CG on the metric inner product. ---
465        // Solve min m(η) = g_x(grad, η) + ½ g_x(η, Hη) within ‖η‖_g ≤ Δ.
466        let n = grad.len();
467        let mut z = Array1::<f64>::zeros(n); // current iterate η
468        let mut r = grad.to_owned(); // residual = grad + Hz (z=0 ⇒ grad)
469        let mut p = -&r; // search direction
470        let r0_norm = g_norm(manifold, x, r.view())?;
471        let tol = (STEIHAUG_CG_FORCING_FACTOR * r0_norm).min(r0_norm * r0_norm);
472
473        // model reduction tracker m(0) − m(z); m(0) = 0 here (constant dropped).
474        // m(z) = g(grad,z) + ½ g(z,Hz); we recompute it at the end for ρ.
475        let max_cg = 2 * n + 1;
476        for _ in 0..max_cg {
477            let hp = objective.hessian_vector_product(x, p.view())?.ok_or(
478                crate::manifold::GeometryError::Unsupported(
479                    "Hessian–vector product became unavailable mid-subproblem",
480                ),
481            )?;
482            let php = g_inner(manifold, x, p.view(), hp.view())?;
483            if php <= 0.0 {
484                // Negative curvature: go to the boundary along p.
485                let (tau, _) = boundary_tau(manifold, x, z.view(), p.view(), delta)?;
486                let eta = &z + &(&p * tau);
487                let red = model_reduction(manifold, objective, x, grad, eta.view())?;
488                return Ok((eta, red, true));
489            }
490            let rr = g_inner(manifold, x, r.view(), r.view())?;
491            let alpha = rr / php;
492            let z_next = &z + &(&p * alpha);
493            if g_norm(manifold, x, z_next.view())? >= delta {
494                // Trust-region boundary crossed: step to it.
495                let (tau, _) = boundary_tau(manifold, x, z.view(), p.view(), delta)?;
496                let eta = &z + &(&p * tau);
497                let red = model_reduction(manifold, objective, x, grad, eta.view())?;
498                return Ok((eta, red, true));
499            }
500            z = z_next;
501            let r_next = &r + &(&hp * alpha);
502            let r_next_norm = g_norm(manifold, x, r_next.view())?;
503            if r_next_norm <= tol {
504                let red = model_reduction(manifold, objective, x, grad, z.view())?;
505                let hit = g_norm(manifold, x, z.view())? >= BOUNDARY_FRAC * delta;
506                return Ok((z, red, hit));
507            }
508            let rr_next = g_inner(manifold, x, r_next.view(), r_next.view())?;
509            let beta = rr_next / rr;
510            p = &(-&r_next) + &(&p * beta);
511            r = r_next;
512        }
513        let red = model_reduction(manifold, objective, x, grad, z.view())?;
514        let hit = g_norm(manifold, x, z.view())? >= BOUNDARY_FRAC * delta;
515        Ok((z, red, hit))
516    }
517
518    /// Cauchy point: the exact minimizer of the model along the steepest-descent
519    /// direction `−grad` within the trust region. With no curvature available
520    /// the model is the decreasing linear `m(τ·(−grad)) = −τ‖grad‖²_g`, whose
521    /// constrained minimizer sits on the boundary `τ = Δ / ‖grad‖_g`, giving a
522    /// predicted reduction `Δ·‖grad‖_g`.
523    fn cauchy_point(
524        &self,
525        manifold: &dyn RiemannianManifold,
526        x: ArrayView1<'_, f64>,
527        grad: ArrayView1<'_, f64>,
528        delta: f64,
529    ) -> GeometryResult<(Array1<f64>, f64, bool)> {
530        let grad_norm = g_norm(manifold, x, grad.view())?;
531        if grad_norm <= 0.0 {
532            return Ok((Array1::<f64>::zeros(grad.len()), 0.0, false));
533        }
534        let tau = delta / grad_norm;
535        let step = &grad.to_owned() * (-tau);
536        // Predicted reduction of the linear model m(0) − m(η) = τ‖grad‖²_g.
537        let predicted = tau * grad_norm * grad_norm;
538        Ok((step, predicted, true))
539    }
540}
541
542/// Largest `τ ≥ 0` with `‖z + τ p‖_g = Δ`, solving the quadratic
543/// `‖p‖²_g τ² + 2 g(z,p) τ + (‖z‖²_g − Δ²) = 0`. Returns `(τ, ‖z + τp‖_g)`.
544fn boundary_tau(
545    manifold: &dyn RiemannianManifold,
546    x: ArrayView1<'_, f64>,
547    z: ArrayView1<'_, f64>,
548    p: ArrayView1<'_, f64>,
549    delta: f64,
550) -> GeometryResult<(f64, f64)> {
551    let pp = g_inner(manifold, x, p, p)?;
552    let zp = g_inner(manifold, x, z, p)?;
553    let zz = g_inner(manifold, x, z, z)?;
554    if pp <= 0.0 {
555        return Ok((0.0, zz.max(0.0).sqrt()));
556    }
557    let c = zz - delta * delta;
558    let disc = (zp * zp - pp * c).max(0.0);
559    let tau = (-zp + disc.sqrt()) / pp;
560    let tau = tau.max(0.0);
561    Ok((tau, delta))
562}
563
564/// Model reduction `m(0) − m(η) = −g(grad, η) − ½ g(η, Hη)`.
565fn model_reduction(
566    manifold: &dyn RiemannianManifold,
567    objective: &mut dyn RiemannianObjective,
568    x: ArrayView1<'_, f64>,
569    grad: ArrayView1<'_, f64>,
570    eta: ArrayView1<'_, f64>,
571) -> GeometryResult<f64> {
572    let lin = g_inner(manifold, x, grad, eta)?;
573    let heta = objective.hessian_vector_product(x, eta)?.ok_or(
574        crate::manifold::GeometryError::Unsupported(
575            "Hessian–vector product unavailable while scoring the model",
576        ),
577    )?;
578    let quad = g_inner(manifold, x, eta, heta.view())?;
579    Ok(-lin - 0.5 * quad)
580}
581
582#[derive(Debug, Clone, PartialEq)]
583pub struct RiemannianLBFGS {
584    pub history: usize,
585    pub step_size: f64,
586    pub max_iter: usize,
587    pub grad_tol: f64,
588}
589
590impl Default for RiemannianLBFGS {
591    fn default() -> Self {
592        Self {
593            history: 10,
594            step_size: 1.0,
595            max_iter: 100,
596            grad_tol: 1.0e-8,
597        }
598    }
599}
600
601/// One stored secant pair, kept with its base point so the two-loop recursion
602/// can transport it into whatever the current tangent space is. `s` and `y`
603/// both live in `T_{base}M`.
604#[derive(Clone)]
605struct SecantPair {
606    base: Array1<f64>,
607    s: Array1<f64>,
608    y: Array1<f64>,
609}
610
611impl RiemannianLBFGS {
612    /// Riemannian L-BFGS with a backtracking-and-expansion Armijo line search.
613    ///
614    /// The search starts at the user-supplied `step_size` (a hint, not a hard
615    /// cap) and first *expands* by doubling while the Armijo sufficient-
616    /// decrease condition continues to hold and the objective is still
617    /// strictly improving. Once expansion stalls, it accepts the best step
618    /// it has seen so far; if even the initial trial violates Armijo, it
619    /// *contracts* by halving until Armijo holds or a safeguard floor is
620    /// reached. This makes the optimizer robust to mis-scaled `step_size`
621    /// inputs (including the Newton-natural α=1 that BFGS expects on
622    /// well-conditioned quadratics) without forcing the caller to retune
623    /// it, and preserves the secant pair (s, y) curvature condition so the
624    /// L-BFGS inverse-Hessian approximation stays SPD.
625    ///
626    /// All inner products use the manifold metric `g_x(·,·)`, and every secant
627    /// pair is *parallel-transported into the current tangent space* before it
628    /// enters the two-loop recursion, so the BFGS algebra never mixes vectors
629    /// living in different tangent spaces (the bug fixed in #616). The freshly
630    /// formed secant pair likewise transports the accepted step from the old
631    /// tangent space into the new one before pairing it with the gradient
632    /// difference, so both `s` and `y` live in `T_{x_new}M`.
633    pub fn minimize(
634        &self,
635        manifold: &dyn RiemannianManifold,
636        objective: &mut dyn RiemannianObjective,
637        initial: ArrayView1<'_, f64>,
638    ) -> GeometryResult<Array1<f64>> {
639        let mut x = initial.to_owned();
640        let d = manifold.ambient_dim();
641        check_len("L-BFGS initial point", x.len(), d)?;
642        if !(self.step_size.is_finite() && self.step_size > 0.0) {
643            return Err(crate::manifold::GeometryError::InvalidPoint(
644                "L-BFGS step size must be finite and positive",
645            ));
646        }
647        if !(self.grad_tol.is_finite() && self.grad_tol >= 0.0) {
648            return Err(crate::manifold::GeometryError::InvalidPoint(
649                "L-BFGS gradient tolerance must be finite and non-negative",
650            ));
651        }
652        let mut history: Vec<SecantPair> = Vec::new();
653        let (mut f_curr, grad_e0) = objective.value_gradient(x.view())?;
654        if !f_curr.is_finite() {
655            return Err(crate::manifold::GeometryError::InvalidPoint(
656                "L-BFGS objective returned a non-finite value",
657            ));
658        }
659        // Riemannian gradient (metric-raised), not a bare tangent projection —
660        // see the trust region above and issue #955. The secant pairs, two-loop
661        // recursion, and Armijo slope are all metric inner products, so they
662        // must operate on the true Riemannian gradient.
663        let mut grad = manifold.riemannian_gradient(x.view(), grad_e0.view())?;
664        // Initial Riemannian gradient norm: the shift-invariant scale of the
665        // relative stationarity test (see `relative_stationarity`).
666        let grad0_norm = g_norm(manifold, x.view(), grad.view())?;
667        let armijo_c: f64 = constants::ARMIJO_C1;
668        let alpha_min: f64 = 1.0e-16;
669        let alpha_max: f64 = 1.0e16;
670        let initial_step = self.step_size;
671        let mut iterations = 0usize;
672        for _ in 0..self.max_iter {
673            // Shift-invariant (relative) stationarity test, identical in form to
674            // the trust region's (see `relative_stationarity`): the current
675            // gradient norm is measured against the initial one, so a fixed
676            // `grad_tol` reads as a *relative* tolerance for a large-scale
677            // objective (e.g. the profiled REML latent objective, issue #879)
678            // while staying invariant under an additive shift of `f` (#954).
679            // Reduces to the absolute test on a unit-scale objective.
680            let grad_norm = g_norm(manifold, x.view(), grad.view())?;
681            if relative_stationarity(grad_norm, grad0_norm) <= self.grad_tol {
682                break;
683            }
684            iterations += 1;
685            let direction = two_loop(manifold, x.view(), grad.view(), &history)?;
686            let direction = -&direction;
687            let slope = g_inner(manifold, x.view(), grad.view(), direction.view())?;
688            // Guard against ascent directions caused by stale curvature; if the
689            // BFGS direction is not a (metric) descent direction, fall back to
690            // the projected steepest-descent direction so progress is
691            // guaranteed.
692            let (direction, slope) = if slope < 0.0 {
693                (direction, slope)
694            } else {
695                let sd = -&grad;
696                let s_sd = g_inner(manifold, x.view(), grad.view(), sd.view())?;
697                (sd, s_sd)
698            };
699            let old_x = x.clone();
700            let old_grad = grad.clone();
701            // --- Armijo line search with bidirectional adaptation, via the
702            // shared expand-then-backtrack primitive: doubles while Armijo
703            // holds and the objective keeps strictly improving (capped at
704            // `alpha_max`), otherwise contracts by the shared halving
705            // schedule. The trial payload carries the retracted point and its
706            // ambient differential so the accepted step's Riemannian gradient
707            // is raised exactly once, after the search.
708            // The pre-migration loops doubled to `alpha_max` and halved to
709            // `alpha_min`; both trial counts are derived by the same
710            // recurrences (exact, unlike a log).
711            let max_expansions = {
712                let mut n = 1_usize;
713                let mut a = initial_step;
714                while a < alpha_max {
715                    n += 1;
716                    a *= 2.0;
717                }
718                n
719            };
720            let max_steps = {
721                let mut n = 0_usize;
722                let mut a = initial_step;
723                while a > alpha_min {
724                    n += 1;
725                    a *= 0.5;
726                }
727                n
728            };
729            let accepted = bidirectional_line_search(
730                f_curr,
731                ExpandConfig {
732                    expand_factor: 2.0,
733                    max_expansions,
734                    max_step: alpha_max,
735                },
736                BacktrackConfig {
737                    initial_step,
738                    max_steps,
739                    ..BacktrackConfig::default()
740                },
741                |alpha| {
742                    let step = &direction * alpha;
743                    let trial_x = manifold.retract(x.view(), step.view())?;
744                    let (f_trial, g_trial_e) = objective.value_gradient(trial_x.view())?;
745                    Ok(Some((f_trial, (trial_x, g_trial_e))))
746                },
747                |alpha, f_trial| {
748                    f_trial.is_finite() && f_trial <= f_curr + armijo_c * alpha * slope
749                },
750            )?;
751            let Some(accepted) = accepted else {
752                // No admissible step found — terminate at the current point.
753                break;
754            };
755            let best_alpha = accepted.step;
756            let best_f = accepted.value;
757            let (best_x, g_trial_e) = accepted.payload;
758            // Riemannian (metric-raised) gradient at the accepted point — the
759            // object the secant pair (s, y) is formed from (#955).
760            let best_grad = manifold.riemannian_gradient(best_x.view(), g_trial_e.view())?;
761            // The accepted tangent step at `old_x` (the actual move taken).
762            let eta = &direction * best_alpha;
763            x = best_x;
764            f_curr = best_f;
765            grad = best_grad;
766
767            // --- Secant pair, formed entirely in T_{x_new}M (the #616 fix). ---
768            // Parallel-transport the accepted step from T_{old_x}M to T_{x}M so
769            // it is a tangent at the NEW point, matching the gradient there. The
770            // old gradient is likewise transported to T_{x}M before subtraction.
771            let path = transport_path(&old_x, &x);
772            let s = manifold.parallel_transport(path.view(), eta.view())?;
773            let transported_old_grad = manifold.parallel_transport(path.view(), old_grad.view())?;
774            let y = &grad - &transported_old_grad;
775            // Commit the (s, y) pair only when the metric curvature condition
776            // g_x(s, y) > 0 holds (strict positivity). This is required for the
777            // implicit BFGS inverse-Hessian update to remain SPD.
778            let sy = g_inner(manifold, x.view(), s.view(), y.view())?;
779            if sy > 1.0e-14 {
780                history.push(SecantPair {
781                    base: x.clone(),
782                    s,
783                    y,
784                });
785                if history.len() > self.history {
786                    history.remove(0);
787                }
788            }
789        }
790        let grad_norm = g_norm(manifold, x.view(), grad.view())?;
791        let residual = relative_stationarity(grad_norm, grad0_norm);
792        if residual <= self.grad_tol {
793            Ok(x)
794        } else {
795            Err(crate::manifold::GeometryError::NonConvergence {
796                context: "Riemannian L-BFGS optimization (relative gradient norm)",
797                iterations,
798                residual,
799                tolerance: self.grad_tol,
800            })
801        }
802    }
803}
804
805/// Build the 2×D point path matrix `[p_from; p_to]` consumed by
806/// [`RiemannianManifold::parallel_transport`].
807fn transport_path(p_from: &Array1<f64>, p_to: &Array1<f64>) -> ndarray::Array2<f64> {
808    let d = p_from.len();
809    let mut path = ndarray::Array2::<f64>::zeros((2, d));
810    path.row_mut(0).assign(p_from);
811    path.row_mut(1).assign(p_to);
812    path
813}
814
815/// L-BFGS two-loop recursion in the CURRENT tangent space `T_xM`.
816///
817/// Each stored secant pair `(s_i, y_i)` lives in `T_{base_i}M`; before it can
818/// participate in the recursion it is parallel-transported into `T_xM`. All
819/// inner products use the manifold metric `g_x(·,·)` at the current point, so
820/// no two vectors from different tangent spaces are ever combined (#616).
821fn two_loop(
822    manifold: &dyn RiemannianManifold,
823    x: ArrayView1<'_, f64>,
824    grad: ArrayView1<'_, f64>,
825    history: &[SecantPair],
826) -> GeometryResult<Array1<f64>> {
827    // Transport every stored pair into the current tangent space once.
828    let mut s_cur: Vec<Array1<f64>> = Vec::with_capacity(history.len());
829    let mut y_cur: Vec<Array1<f64>> = Vec::with_capacity(history.len());
830    for pair in history {
831        let path = transport_path(&pair.base, &x.to_owned());
832        s_cur.push(manifold.parallel_transport(path.view(), pair.s.view())?);
833        y_cur.push(manifold.parallel_transport(path.view(), pair.y.view())?);
834    }
835
836    let mut q = grad.to_owned();
837    let mut alpha = vec![0.0; history.len()];
838    let mut rho = vec![0.0; history.len()];
839    for i in (0..history.len()).rev() {
840        let sy = g_inner(manifold, x, s_cur[i].view(), y_cur[i].view())?;
841        rho[i] = 1.0 / sy;
842        alpha[i] = rho[i] * g_inner(manifold, x, s_cur[i].view(), q.view())?;
843        q = &q - &(&y_cur[i] * alpha[i]);
844    }
845    let mut r = q;
846    if let (Some(s), Some(y)) = (s_cur.last(), y_cur.last()) {
847        let yy = g_inner(manifold, x, y.view(), y.view())?;
848        if yy > 1.0e-14 {
849            let sy = g_inner(manifold, x, s.view(), y.view())?;
850            r = &r * (sy / yy);
851        }
852    }
853    for i in 0..history.len() {
854        let beta = rho[i] * g_inner(manifold, x, y_cur[i].view(), r.view())?;
855        r = &r + &(&s_cur[i] * (alpha[i] - beta));
856    }
857    Ok(r)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use crate::EuclideanManifold;
864    use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
865
866    struct IndefiniteLine;
867
868    impl RiemannianManifold for IndefiniteLine {
869        fn dim(&self) -> usize {
870            1
871        }
872
873        fn tangent_basis(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>> {
874            assert_eq!(point.len(), 1, "IndefiniteLine points are one-dimensional");
875            Ok(Array2::eye(1))
876        }
877
878        fn exp_map(
879            &self,
880            point: ArrayView1<'_, f64>,
881            tangent_vec: ArrayView1<'_, f64>,
882        ) -> GeometryResult<Array1<f64>> {
883            Ok(&point.to_owned() + &tangent_vec)
884        }
885
886        fn log_map(
887            &self,
888            p_from: ArrayView1<'_, f64>,
889            p_to: ArrayView1<'_, f64>,
890        ) -> GeometryResult<Array1<f64>> {
891            Ok(&p_to.to_owned() - &p_from)
892        }
893
894        fn parallel_transport(
895            &self,
896            point_along: ArrayView2<'_, f64>,
897            vec: ArrayView1<'_, f64>,
898        ) -> GeometryResult<Array1<f64>> {
899            assert_eq!(
900                point_along.ncols(),
901                1,
902                "IndefiniteLine transport paths are one-dimensional"
903            );
904            assert_eq!(vec.len(), 1, "IndefiniteLine tangents are one-dimensional");
905            Ok(vec.to_owned())
906        }
907
908        fn metric_tensor(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>> {
909            assert_eq!(point.len(), 1, "IndefiniteLine points are one-dimensional");
910            Ok(ndarray::array![[-1.0]])
911        }
912
913        fn sectional_curvature(
914            &self,
915            point: ArrayView1<'_, f64>,
916            tangent_pair: (ArrayView1<'_, f64>, ArrayView1<'_, f64>),
917        ) -> GeometryResult<f64> {
918            assert_eq!(point.len(), 1, "IndefiniteLine points are one-dimensional");
919            assert_eq!(
920                tangent_pair.0.len(),
921                1,
922                "IndefiniteLine tangents are one-dimensional"
923            );
924            assert_eq!(
925                tangent_pair.1.len(),
926                1,
927                "IndefiniteLine tangents are one-dimensional"
928            );
929            Ok(0.0)
930        }
931    }
932
933    /// Scalar objective `f(x) = x²` on the 1-D Euclidean line. Gradient `2x`,
934    /// Hessian `2`, exposed as an HVP so the trust region runs Steihaug-CG.
935    struct Square;
936    impl RiemannianObjective for Square {
937        fn value_gradient(
938            &mut self,
939            point: ArrayView1<'_, f64>,
940        ) -> GeometryResult<(f64, Array1<f64>)> {
941            let x = point[0];
942            Ok((x * x, Array1::from_vec(vec![2.0 * x])))
943        }
944        fn hessian_vector_product(
945            &mut self,
946            point: ArrayView1<'_, f64>,
947            tangent: ArrayView1<'_, f64>,
948        ) -> GeometryResult<Option<Array1<f64>>> {
949            assert!(point.iter().all(|value| value.is_finite()));
950            check_len("hessian_vector_product tangent", tangent.len(), point.len())?;
951            Ok(Some(&tangent.to_owned() * 2.0))
952        }
953    }
954
955    /// Gradient-only variant of `f(x)=x²` (no HVP) to exercise the Cauchy-point
956    /// branch of the trust region.
957    struct SquareGradOnly;
958    impl RiemannianObjective for SquareGradOnly {
959        fn value_gradient(
960            &mut self,
961            point: ArrayView1<'_, f64>,
962        ) -> GeometryResult<(f64, Array1<f64>)> {
963            let x = point[0];
964            Ok((x * x, Array1::from_vec(vec![2.0 * x])))
965        }
966    }
967
968    /// General convex quadratic `f(x) = ½ xᵀ A x − bᵀ x` on Euclidean R^n with
969    /// SPD `A`; minimizer solves `A x = b`. Provides an exact HVP `A v`.
970    struct Quadratic {
971        a: ndarray::Array2<f64>,
972        b: Array1<f64>,
973    }
974    impl RiemannianObjective for Quadratic {
975        fn value_gradient(
976            &mut self,
977            point: ArrayView1<'_, f64>,
978        ) -> GeometryResult<(f64, Array1<f64>)> {
979            let ax = self.a.dot(&point.to_owned());
980            let val = 0.5 * point.dot(&ax) - self.b.dot(&point.to_owned());
981            let grad = &ax - &self.b;
982            Ok((val, grad))
983        }
984        fn hessian_vector_product(
985            &mut self,
986            point: ArrayView1<'_, f64>,
987            tangent: ArrayView1<'_, f64>,
988        ) -> GeometryResult<Option<Array1<f64>>> {
989            assert!(point.iter().all(|value| value.is_finite()));
990            check_len("hessian_vector_product tangent", tangent.len(), point.len())?;
991            Ok(Some(self.a.dot(&tangent.to_owned())))
992        }
993    }
994
995    /// (#615 counterexample) A correct trust region on `f(x)=x²` from `x₀=0.1`
996    /// with `Δ=1` must CONVERGE to 0 (not oscillate), monotonically driving `f`
997    /// down — never increasing it on an accepted iterate.
998    #[test]
999    fn trust_region_converges_on_square_steihaug() {
1000        let manifold = EuclideanManifold::new(1);
1001        let tr = RiemannianTrustRegion {
1002            radius: 1.0,
1003            max_radius: 1.0e6,
1004            max_iter: 100,
1005            grad_tol: 1.0e-12,
1006        };
1007        let mut obj = Square;
1008        let x0 = Array1::from_vec(vec![0.1]);
1009        let x = tr
1010            .minimize(&manifold, &mut obj, x0.view())
1011            .expect("TR runs");
1012        assert!(
1013            x[0].abs() < 1.0e-6,
1014            "trust region must converge to 0, got {}",
1015            x[0]
1016        );
1017    }
1018
1019    /// The trust region must never increase `f` across accepted iterates. We
1020    /// check the monotone-descent invariant directly by stepping the public
1021    /// `minimize` from a sequence of decreasing budgets and confirming the
1022    /// returned value is below the start value, and that from `x₀=0.1` it does
1023    /// not return a point with larger `|x|`.
1024    #[test]
1025    fn trust_region_never_increases_objective() {
1026        let manifold = EuclideanManifold::new(1);
1027        let tr = RiemannianTrustRegion {
1028            radius: 1.0,
1029            max_radius: 1.0e6,
1030            max_iter: 1,
1031            grad_tol: 1.0e-12,
1032        };
1033        let mut obj = Square;
1034        // A single TR iteration from 0.1: with exact Hessian the Newton step
1035        // lands at the minimum (inside Δ=1), ρ=1, so it must be accepted and f
1036        // must strictly decrease.
1037        let x0 = Array1::from_vec(vec![0.1]);
1038        let f0 = obj.value_gradient(x0.view()).unwrap().0;
1039        let x1 = tr
1040            .minimize(&manifold, &mut obj, x0.view())
1041            .expect("TR runs");
1042        let f1 = obj.value_gradient(x1.view()).unwrap().0;
1043        assert!(f1 <= f0, "objective increased: {f0} -> {f1}");
1044        assert!(x1[0].abs() <= x0[0].abs() + 1e-15, "moved away from min");
1045    }
1046
1047    /// Cauchy-point branch (no HVP) must still be a real trust-region method:
1048    /// from `x₀=0.1`, `Δ=1` on `f(x)=x²` it converges toward 0 and never
1049    /// oscillates upward in `f`.
1050    #[test]
1051    fn trust_region_cauchy_point_converges() {
1052        let manifold = EuclideanManifold::new(1);
1053        let tr = RiemannianTrustRegion {
1054            radius: 1.0,
1055            max_radius: 1.0e6,
1056            max_iter: 500,
1057            grad_tol: 1.0e-12,
1058        };
1059        let mut obj = SquareGradOnly;
1060        let x0 = Array1::from_vec(vec![0.1]);
1061        let x = tr
1062            .minimize(&manifold, &mut obj, x0.view())
1063            .expect("TR runs");
1064        assert!(
1065            x[0].abs() < 1.0e-6,
1066            "Cauchy-point trust region must converge to 0, got {}",
1067            x[0]
1068        );
1069    }
1070
1071    /// Steihaug-CG trust region on a 3-D SPD quadratic must reach the exact
1072    /// minimizer `A⁻¹ b`.
1073    #[test]
1074    fn trust_region_solves_spd_quadratic() {
1075        let manifold = EuclideanManifold::new(3);
1076        let a = ndarray::array![[4.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 1.0, 2.0],];
1077        let b = Array1::from_vec(vec![1.0, 2.0, -1.0]);
1078        // Reference solution A x = b.
1079        let x_ref = crate::manifold::inverse(&a).unwrap().dot(&b);
1080        let mut obj = Quadratic { a, b };
1081        let tr = RiemannianTrustRegion {
1082            radius: 1.0,
1083            max_radius: 1.0e6,
1084            max_iter: 200,
1085            grad_tol: 1.0e-12,
1086        };
1087        let x0 = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1088        let x = tr
1089            .minimize(&manifold, &mut obj, x0.view())
1090            .expect("TR runs");
1091        for i in 0..3 {
1092            assert!(
1093                (x[i] - x_ref[i]).abs() < 1.0e-6,
1094                "component {i}: got {}, want {}",
1095                x[i],
1096                x_ref[i]
1097            );
1098        }
1099    }
1100
1101    /// (#616 sanity) Riemannian L-BFGS on a Euclidean SPD quadratic must reduce
1102    /// the objective and converge to the analytic minimizer `A⁻¹ b`.
1103    #[test]
1104    fn lbfgs_reduces_euclidean_quadratic() {
1105        let manifold = EuclideanManifold::new(3);
1106        let a = ndarray::array![[5.0, 1.0, 0.5], [1.0, 4.0, 1.0], [0.5, 1.0, 3.0],];
1107        let b = Array1::from_vec(vec![2.0, -1.0, 0.5]);
1108        let x_ref = crate::manifold::inverse(&a).unwrap().dot(&b);
1109        let mut obj = Quadratic { a, b };
1110        let lbfgs = RiemannianLBFGS {
1111            history: 10,
1112            step_size: 1.0,
1113            max_iter: 200,
1114            grad_tol: 1.0e-10,
1115        };
1116        let x0 = Array1::from_vec(vec![0.0, 0.0, 0.0]);
1117        let f0 = obj.value_gradient(x0.view()).unwrap().0;
1118        let x = lbfgs
1119            .minimize(&manifold, &mut obj, x0.view())
1120            .expect("L-BFGS runs");
1121        let f1 = obj.value_gradient(x.view()).unwrap().0;
1122        assert!(f1 < f0, "L-BFGS did not reduce the quadratic: {f0} -> {f1}");
1123        for i in 0..3 {
1124            assert!(
1125                (x[i] - x_ref[i]).abs() < 1.0e-6,
1126                "component {i}: got {}, want {}",
1127                x[i],
1128                x_ref[i]
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn optimizers_refuse_nonstationary_zero_budget_iterates() {
1135        let manifold = EuclideanManifold::new(1);
1136        let x0 = Array1::from_vec(vec![1.0]);
1137
1138        let mut trust_objective = Square;
1139        let trust = RiemannianTrustRegion {
1140            max_iter: 0,
1141            ..RiemannianTrustRegion::default()
1142        };
1143        let trust_error = trust
1144            .minimize(&manifold, &mut trust_objective, x0.view())
1145            .expect_err("a zero-budget nonstationary trust-region run must not mint a point");
1146        assert!(matches!(
1147            trust_error,
1148            crate::manifold::GeometryError::NonConvergence {
1149                iterations: 0,
1150                residual,
1151                ..
1152            } if residual > trust.grad_tol
1153        ));
1154
1155        let mut lbfgs_objective = Square;
1156        let lbfgs = RiemannianLBFGS {
1157            max_iter: 0,
1158            ..RiemannianLBFGS::default()
1159        };
1160        let lbfgs_error = lbfgs
1161            .minimize(&manifold, &mut lbfgs_objective, x0.view())
1162            .expect_err("a zero-budget nonstationary L-BFGS run must not mint a point");
1163        assert!(matches!(
1164            lbfgs_error,
1165            crate::manifold::GeometryError::NonConvergence {
1166                iterations: 0,
1167                residual,
1168                ..
1169            } if residual > lbfgs.grad_tol
1170        ));
1171    }
1172
1173    #[test]
1174    fn nonfinite_gradient_cannot_be_misread_as_zero_norm() {
1175        struct NanGradient;
1176        impl RiemannianObjective for NanGradient {
1177            fn value_gradient(
1178                &mut self,
1179                point: ArrayView1<'_, f64>,
1180            ) -> GeometryResult<(f64, Array1<f64>)> {
1181                assert_eq!(point.len(), 1, "NanGradient is one-dimensional");
1182                Ok((0.0, Array1::from_vec(vec![f64::NAN])))
1183            }
1184        }
1185
1186        let manifold = EuclideanManifold::new(1);
1187        let x0 = Array1::zeros(1);
1188        let mut objective = NanGradient;
1189        let error = RiemannianTrustRegion::default()
1190            .minimize(&manifold, &mut objective, x0.view())
1191            .expect_err("NaN gradient must never certify stationarity");
1192        assert!(matches!(
1193            error,
1194            crate::manifold::GeometryError::NonConvergence { residual, .. }
1195                if residual.is_infinite()
1196        ));
1197    }
1198
1199    #[test]
1200    fn overflowed_metric_error_bound_cannot_certify_zero_norm() {
1201        // The signed quadratic cancels to zero in this order, while the sum of
1202        // absolute terms overflows. Treating an infinite backward-error band
1203        // as a valid tolerance would turn this indefinite quadratic into a
1204        // zero norm and falsely certify stationarity.
1205        let vector = ndarray::array![1.0, 1.0, 1.0, 1.0];
1206        let metric_product = ndarray::array![9.0e307, -9.0e307, 9.0e307, -9.0e307];
1207        let error = metric_norm_from_product(vector.view(), metric_product.view())
1208            .expect_err("an overflowed norm error bound must be rejected");
1209        assert!(matches!(
1210            error,
1211            crate::manifold::GeometryError::InvalidPoint(
1212                "Riemannian metric norm error bound overflowed"
1213            )
1214        ));
1215    }
1216
1217    #[test]
1218    fn indefinite_metric_cannot_be_clamped_into_false_stationarity() {
1219        let manifold = IndefiniteLine;
1220        let mut objective = Square;
1221        let x0 = Array1::from_vec(vec![1.0]);
1222        let error = RiemannianTrustRegion::default()
1223            .minimize(&manifold, &mut objective, x0.view())
1224            .expect_err("an indefinite metric is not a Riemannian norm");
1225        assert!(matches!(
1226            error,
1227            crate::manifold::GeometryError::InvalidPoint(
1228                "Riemannian metric produced a negative squared norm"
1229            )
1230        ));
1231    }
1232}