Skip to main content

gam_geometry/
optimizer.rs

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