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