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}