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