Skip to main content

gam_geometry/
manifold.rs

1use std::fmt;
2
3use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
4
5pub const GEOMETRY_EPS: f64 = 1.0e-12;
6
7#[derive(Debug, Clone, PartialEq)]
8pub enum GeometryError {
9    DimensionMismatch {
10        context: &'static str,
11        expected: usize,
12        got: usize,
13    },
14    InvalidPoint(&'static str),
15    Singular(&'static str),
16    /// A manifold primitive has no implementation for this manifold and must
17    /// not silently fall back to a wrong default (e.g. a curved-manifold VJP
18    /// for which no closed form is wired up yet).
19    Unsupported(&'static str),
20    /// An iterative geometry primitive exhausted or stalled without satisfying
21    /// its analytic first-order certificate. The evidence is carried in the
22    /// error so callers can distinguish non-convergence from invalid geometry
23    /// and inspect the achieved residual rather than receiving a partial point.
24    NonConvergence {
25        context: &'static str,
26        iterations: usize,
27        residual: f64,
28        tolerance: f64,
29    },
30    /// A Karcher solve reached first-order stationarity on a positively curved
31    /// manifold, but the weighted support does not fit inside the analytic
32    /// strongly-convex ball that certifies this stationary point as the unique
33    /// global Fréchet mean. Returning the local basin would make the chart
34    /// origin depend on initialization; callers must instead provide an
35    /// explicit base point or better-localized data.
36    FrechetMeanNotGloballyCertified {
37        context: &'static str,
38        stationarity_residual: f64,
39        tolerance: f64,
40        support_radius: f64,
41        uniqueness_radius: f64,
42    },
43}
44
45impl fmt::Display for GeometryError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Self::DimensionMismatch {
49                context,
50                expected,
51                got,
52            } => write!(f, "{context} expected length {expected}, got {got}"),
53            Self::InvalidPoint(message) => write!(f, "invalid manifold point: {message}"),
54            Self::Singular(message) => write!(f, "singular geometry operation: {message}"),
55            Self::Unsupported(message) => write!(f, "unsupported geometry operation: {message}"),
56            Self::NonConvergence {
57                context,
58                iterations,
59                residual,
60                tolerance,
61            } => write!(
62                f,
63                "{context} did not converge after {iterations} iterations: \
64                 stationarity residual {residual:.6e} exceeds tolerance {tolerance:.6e}"
65            ),
66            Self::FrechetMeanNotGloballyCertified {
67                context,
68                stationarity_residual,
69                tolerance,
70                support_radius,
71                uniqueness_radius,
72            } => write!(
73                f,
74                "{context} reached stationarity ({stationarity_residual:.6e} <= \
75                 {tolerance:.6e}) but its weighted support radius \
76                 {support_radius:.6e} is not below the global-uniqueness radius \
77                 {uniqueness_radius:.6e}"
78            ),
79        }
80    }
81}
82
83impl std::error::Error for GeometryError {}
84
85pub type GeometryResult<T> = Result<T, GeometryError>;
86
87pub trait RiemannianManifold: Send + Sync {
88    fn dim(&self) -> usize;
89
90    fn ambient_dim(&self) -> usize {
91        self.dim()
92    }
93
94    fn tangent_basis(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>>;
95
96    fn exp_map(
97        &self,
98        point: ArrayView1<'_, f64>,
99        tangent_vec: ArrayView1<'_, f64>,
100    ) -> GeometryResult<Array1<f64>>;
101
102    fn log_map(
103        &self,
104        p_from: ArrayView1<'_, f64>,
105        p_to: ArrayView1<'_, f64>,
106    ) -> GeometryResult<Array1<f64>>;
107
108    fn parallel_transport(
109        &self,
110        point_along: ArrayView2<'_, f64>,
111        vec: ArrayView1<'_, f64>,
112    ) -> GeometryResult<Array1<f64>>;
113
114    fn metric_tensor(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Array2<f64>>;
115
116    fn christoffel_symbols(&self, point: ArrayView1<'_, f64>) -> GeometryResult<Vec<Array2<f64>>> {
117        check_len("Christoffel point", point.len(), self.ambient_dim())?;
118        Err(GeometryError::Unsupported(
119            "Christoffel symbols require a manifold-specific local chart",
120        ))
121    }
122
123    fn sectional_curvature(
124        &self,
125        point: ArrayView1<'_, f64>,
126        tangent_pair: (ArrayView1<'_, f64>, ArrayView1<'_, f64>),
127    ) -> GeometryResult<f64>;
128
129    fn project_tangent(
130        &self,
131        point: ArrayView1<'_, f64>,
132        vec: ArrayView1<'_, f64>,
133    ) -> GeometryResult<Array1<f64>> {
134        // Default projection is the identity (Euclidean-flat tangent space).
135        // Validate that BOTH the base point and the tangent vector live in the
136        // ambient space so a caller passing a wrong-length vector fails fast
137        // here rather than producing a silently mis-shaped tangent vector. The
138        // tangent of `T_pM` is represented in the same ambient coordinates as
139        // the point, so its length must equal `ambient_dim()` too.
140        let expected = self.ambient_dim();
141        if point.len() != expected {
142            return Err(GeometryError::DimensionMismatch {
143                context: "project_tangent point",
144                expected,
145                got: point.len(),
146            });
147        }
148        if vec.len() != expected {
149            return Err(GeometryError::DimensionMismatch {
150                context: "project_tangent vector",
151                expected,
152                got: vec.len(),
153            });
154        }
155        Ok(vec.to_owned())
156    }
157
158    /// Riemannian gradient of a scalar `f` raised from its **ambient Euclidean
159    /// differential** `e` — the vector `∂f/∂x` in ambient coordinates that an
160    /// objective returns from its `value_gradient`.
161    ///
162    /// The Riemannian gradient is the Riesz representative of the differential
163    /// under the manifold metric `g`: the unique tangent vector `v` satisfying
164    ///
165    /// ```text
166    ///   g_x(v, ξ) = Df_x[ξ] = ⟨e, ξ⟩   for every tangent ξ.
167    /// ```
168    ///
169    /// Orthogonally projecting `e` onto the tangent space ([`project_tangent`])
170    /// produces `v` **only** for the embedded/identity metric. For a genuine
171    /// Riemannian metric (affine-invariant SPD, canonical Stiefel, …) the
172    /// differential must be *raised through the metric* — projecting alone gives
173    /// the wrong direction and the wrong slope, so any model linear term or
174    /// Armijo slope built from it is not even first-order accurate (issue #955).
175    ///
176    /// The default raises `e` in a tangent basis `B = tangent_basis(x)` against
177    /// the metric `G = metric_tensor(x)`:
178    ///
179    /// ```text
180    ///   v = B (Bᵀ G B)⁻¹ Bᵀ e.
181    /// ```
182    ///
183    /// This is the Riesz representative for ANY basis `B` of `T_xM` (proof: for
184    /// `ξ = B c`, `g_x(v, ξ) = eᵀ B (Bᵀ G B)⁻¹ (Bᵀ G B) c = eᵀ B c = ⟨e, ξ⟩`),
185    /// and it collapses to the orthogonal tangent projection `B Bᵀ e` exactly
186    /// when `B` is metric-orthonormal / the metric is the embedded one. It is the
187    /// mathematically correct fallback, so a future non-identity-metric manifold
188    /// is never silently first-order wrong.
189    ///
190    /// Manifolds whose tangent projection already coincides with this (every
191    /// *embedded* manifold carrying the induced metric — Euclidean, Sphere,
192    /// Circle, Torus, Grassmann) override with the O(m) `project_tangent`;
193    /// manifolds with a slick closed form (SPD: `P·sym(E)·P`; Stiefel:
194    /// `E − Y Eᵀ Y`) override with that, avoiding the dense `m×m` metric tensor.
195    fn riemannian_gradient(
196        &self,
197        point: ArrayView1<'_, f64>,
198        euclidean_grad: ArrayView1<'_, f64>,
199    ) -> GeometryResult<Array1<f64>> {
200        let m = self.ambient_dim();
201        check_len("riemannian_gradient point", point.len(), m)?;
202        check_len(
203            "riemannian_gradient euclidean_grad",
204            euclidean_grad.len(),
205            m,
206        )?;
207        let b = self.tangent_basis(point)?; // m × d
208        let g = self.metric_tensor(point)?; // m × m
209        // Bᵀ e  (length d) and the Gram matrix Bᵀ G B  (d × d).
210        let bt = b.t();
211        let bte = bt.dot(&euclidean_grad.to_owned());
212        let gb = g.dot(&b);
213        let btgb = bt.dot(&gb);
214        if btgb.nrows() == 0 {
215            // A zero-dimensional tangent space (no degrees of freedom): the only
216            // tangent vector is 0.
217            return Ok(Array1::<f64>::zeros(m));
218        }
219        // Solve (BᵀGB) c = Bᵀ e for the basis coordinates of v, then v = B c.
220        let c = inverse(&btgb)?.dot(&bte);
221        Ok(b.dot(&c))
222    }
223
224    /// Take one metric-correct Riemannian gradient-descent step.
225    ///
226    /// `euclidean_grad` is the ambient Euclidean differential supplied by an
227    /// external objective (for example, PyTorch). This method raises that
228    /// differential through the manifold metric, scales the resulting tangent
229    /// vector by `-learning_rate`, and retracts from `point`. Keeping the whole
230    /// operation in the geometry layer prevents callers from accidentally
231    /// retracting a merely projected Euclidean differential on manifolds whose
232    /// metric is not the embedded Euclidean metric.
233    fn riemannian_gradient_step(
234        &self,
235        point: ArrayView1<'_, f64>,
236        euclidean_grad: ArrayView1<'_, f64>,
237        learning_rate: f64,
238    ) -> GeometryResult<Array1<f64>> {
239        if !learning_rate.is_finite() || learning_rate <= 0.0 {
240            return Err(GeometryError::InvalidPoint(
241                "Riemannian gradient-step learning rate must be finite and positive",
242            ));
243        }
244        // `euclidean_grad` is a differential/covector.  Raise it through the
245        // metric first, then retract the resulting tangent vector:
246        //
247        //   e = df/dx,
248        //   grad f = Raise_x(e),
249        //   x_next = Retr_x(-eta grad f).
250        //
251        // Projecting e and retracting it directly is correct only for an
252        // induced Euclidean metric, not for affine-SPD or canonical Stiefel.
253        let gradient = self.riemannian_gradient(point, euclidean_grad)?;
254        let step = gradient.mapv(|value| -learning_rate * value);
255        self.retract(point, step.view())
256    }
257
258    fn retract(
259        &self,
260        point: ArrayView1<'_, f64>,
261        tangent_vec: ArrayView1<'_, f64>,
262    ) -> GeometryResult<Array1<f64>> {
263        self.exp_map(point, tangent_vec)
264    }
265
266    /// Whether [`retract`](Self::retract) is at least a SECOND-ORDER retraction,
267    /// i.e. `D²(f∘R_x)(0) = Hess f(x)` for all `f`, so the trust-region quadratic
268    /// model built from the Riemannian Hessian is a valid second-order model of
269    /// `f` along the retraction (issue #956).
270    ///
271    /// Manifolds whose `retract` is the exponential map or another second-order
272    /// retraction return `true` (the default — the default `retract` *is*
273    /// `exp_map`, which is second-order). A manifold exposing only a FIRST-ORDER
274    /// retraction (e.g. the Stiefel/Grassmann QR retraction `qf(Y + Δ)`, whose
275    /// acceleration at `0` is not normal to the manifold) must override this to
276    /// `false`: the linear model term `Df_x[η]` is retraction-independent and
277    /// stays correct, but the Riemannian-Hessian quadratic term is *not* the
278    /// second derivative of `f∘R_x` and would corrupt the predicted-vs-actual
279    /// reduction ratio `ρ` and hence the trust-region radius control. The trust
280    /// region falls back to the first-order-correct Cauchy model in that case.
281    fn retraction_is_second_order(&self) -> bool {
282        true
283    }
284
285    /// Vector–Jacobian product of the ambient map `exp_p(v)`.
286    ///
287    /// Given a cotangent `grad_output` w.r.t. the ambient output of
288    /// [`exp_map`](Self::exp_map), return `(grad_point, grad_tangent)`, the
289    /// pullbacks w.r.t. the base point `p` and the (raw, unprojected) tangent
290    /// input `v`. This is the analytic backward used by reverse-mode autodiff
291    /// wrappers (e.g. the Python `torch.autograd.Function` around
292    /// `manifold_exp_map`); it must never be the silent straight-through
293    /// identity for a curved manifold.
294    ///
295    /// The default is the exact VJP for *flat* manifolds, where
296    /// `exp_p(v) = p + v` in ambient coordinates and so both Jacobians are the
297    /// identity (Euclidean, Circle, Torus, and products thereof). Curved
298    /// manifolds **must** override this with their analytic Jacobi-field VJP;
299    /// a manifold without a closed form must override it to return an error
300    /// rather than inherit the wrong identity default.
301    fn exp_map_vjp(
302        &self,
303        point: ArrayView1<'_, f64>,
304        tangent_vec: ArrayView1<'_, f64>,
305        grad_output: ArrayView1<'_, f64>,
306    ) -> GeometryResult<(Array1<f64>, Array1<f64>)> {
307        let m = self.ambient_dim();
308        check_len("exp_map_vjp point", point.len(), m)?;
309        check_len("exp_map_vjp tangent", tangent_vec.len(), m)?;
310        check_len("exp_map_vjp grad_output", grad_output.len(), m)?;
311        Ok((grad_output.to_owned(), grad_output.to_owned()))
312    }
313}
314
315#[derive(Debug, Clone, PartialEq)]
316pub enum ManifoldSpec {
317    Euclidean(usize),
318    Circle,
319    Sphere { intrinsic_dim: usize },
320    Torus { dim: usize },
321    Grassmann { k: usize, n: usize },
322    Stiefel { k: usize, n: usize },
323    Spd { n: usize },
324    Product(Vec<ManifoldSpec>),
325}
326
327impl ManifoldSpec {
328    /// Instantiate the concrete [`RiemannianManifold`] for this descriptor.
329    ///
330    /// Fallible because the constrained-frame families have nonempty domains:
331    /// `Gr(k, n)` and `St(n, k)` exist only for `1 ≤ k ≤ n`. An out-of-domain
332    /// descriptor is rejected here (and recursively for [`Product`] parts)
333    /// before any dimension, projection, exponential, or curvature computation
334    /// can run on a nonexistent manifold.
335    ///
336    /// [`Product`]: Self::Product
337    pub fn build(&self) -> GeometryResult<Box<dyn RiemannianManifold>> {
338        match self {
339            Self::Euclidean(dim) => Ok(Box::new(crate::EuclideanManifold::new(*dim))),
340            Self::Circle => Ok(Box::new(crate::CircleManifold::new())),
341            Self::Sphere { intrinsic_dim } => {
342                Ok(Box::new(crate::SphereManifold::new(*intrinsic_dim)))
343            }
344            Self::Torus { dim } => Ok(Box::new(crate::TorusManifold::new(*dim))),
345            Self::Grassmann { k, n } => Ok(Box::new(crate::GrassmannManifold::new(*k, *n)?)),
346            Self::Stiefel { k, n } => Ok(Box::new(crate::StiefelManifold::new(*k, *n)?)),
347            Self::Spd { n } => Ok(Box::new(crate::SpdManifold::new(*n))),
348            Self::Product(parts) => {
349                let mut built = Vec::with_capacity(parts.len());
350                for part in parts {
351                    built.push(part.build()?);
352                }
353                Ok(Box::new(crate::ProductManifold::new(built)))
354            }
355        }
356    }
357}
358
359pub(crate) const fn check_len(
360    context: &'static str,
361    got: usize,
362    expected: usize,
363) -> GeometryResult<()> {
364    if got == expected {
365        Ok(())
366    } else {
367        Err(GeometryError::DimensionMismatch {
368            context,
369            expected,
370            got,
371        })
372    }
373}
374
375pub(crate) fn dot(a: ArrayView1<'_, f64>, b: ArrayView1<'_, f64>) -> f64 {
376    assert_eq!(a.len(), b.len());
377    let mut out = 0.0;
378    for i in 0..a.len() {
379        out += a[i] * b[i];
380    }
381    out
382}
383
384/// Multi-GPU row-tiled matrix product `A·B`, fanned across **all** usable
385/// devices.
386///
387/// `A` is `m×k` and `B` is `k×n`; the result is `m×n`. The single-device
388/// `fast_ab` shim already offloads this GEMM, but it pins the launch to the
389/// primary device. For a tall `A` (many independent output rows — the common
390/// case when a manifold operation is applied to a large batch of points/atoms),
391/// the rows split cleanly across the pool: we reshape `A` into a
392/// `tiles × rows_per_tile × k` batch and call the broadcast-`B` strided-batched
393/// GEMM, which [`crate::gpu::pool::scatter_batched`]es one cuBLAS call per device
394/// on its own bound context (`b` is shared across every tile). The output tiles
395/// are stitched back into the `m×n` result. Any leftover rows that don't fill a
396/// whole tile, and the entire batch when the pool has one device / the workload
397/// is below the multi-GPU floor / the runtime is unavailable, fall through to the
398/// auto-dispatch `fast_ab` (single-device GPU or faer). f64 throughout, so the
399/// result is identical regardless of which path produced it.
400///
401/// Choosing the tiling: we target as many equal tiles as there are output rows
402/// can support while keeping each tile a non-trivial GEMM, so the batch axis is
403/// long enough to cross `crate::gpu::linalg_dispatch`'s multi-GPU batch floor and spread
404/// across every device.
405pub(crate) fn fast_ab_rows_multi_gpu(
406    a: ArrayView2<'_, f64>,
407    b: ArrayView2<'_, f64>,
408) -> Array2<f64> {
409    use gam_linalg::faer_ndarray::fast_ab;
410    let (m, k) = a.dim();
411    let (kb, n) = b.dim();
412    assert_eq!(k, kb, "fast_ab_rows_multi_gpu inner dimension mismatch");
413
414    // Only worth the reshape/stitch overhead when the pool actually has more than
415    // one device and there are enough rows to tile across it; otherwise the plain
416    // single-device shim is strictly better.
417    let multi_gpu = gam_linalg::gpu_hook::gpu_dispatch().is_some_and(|d| d.device_count() > 1);
418    // The batch axis must clear the multi-GPU floor used inside the dispatch
419    // layer (64) for the split to engage, so we need at least that many tiles.
420    const MIN_TILES: usize = 64;
421    const MIN_TILE_ROWS: usize = 4;
422    if multi_gpu && m >= MIN_TILES * MIN_TILE_ROWS && n > 0 {
423        let rows_per_tile = (m / MIN_TILES).max(MIN_TILE_ROWS);
424        let tiles = m / rows_per_tile;
425        let covered = tiles * rows_per_tile;
426        // Reshape the first `covered` rows into a tiles×rows_per_tile×k batch
427        // (row-major reshape is exactly the row-block tiling we want).
428        let a3 = a
429            .slice(ndarray::s![0..covered, ..])
430            .to_owned()
431            .into_shape_with_order((tiles, rows_per_tile, k));
432        if let Ok(a3) = a3 {
433            if let Some(result3) = gam_linalg::gpu_hook::gpu_dispatch()
434                .and_then(|d| d.try_fast_ab_broadcast_b_batched(a3.view(), b.view()))
435            {
436                let mut out = Array2::<f64>::zeros((m, n));
437                for t in 0..tiles {
438                    let block = result3.index_axis(ndarray::Axis(0), t);
439                    out.slice_mut(ndarray::s![t * rows_per_tile..(t + 1) * rows_per_tile, ..])
440                        .assign(&block);
441                }
442                // Tail rows that didn't fill a whole tile finish on the
443                // single-device shim; the result is bit-identical f64.
444                if covered < m {
445                    let tail = fast_ab(&a.slice(ndarray::s![covered..m, ..]), &b);
446                    out.slice_mut(ndarray::s![covered..m, ..]).assign(&tail);
447                }
448                return out;
449            }
450        }
451    }
452    // Single device / small batch / no runtime: plain auto-dispatch GEMM.
453    fast_ab(&a, &b)
454}
455
456pub(crate) fn norm(a: ArrayView1<'_, f64>) -> f64 {
457    dot(a, a).sqrt()
458}
459
460/// Metric inner product `aᵀ G b` for a (symmetric) metric tensor `G`.
461///
462/// For a manifold whose `metric_tensor` is the ambient identity this reduces
463/// to the Euclidean `dot`; for one with a genuine Riemannian metric (e.g. the
464/// affine-invariant SPD metric) it evaluates the correct geometric inner
465/// product on the tangent space.
466pub(crate) fn quad_form(
467    g: ArrayView2<'_, f64>,
468    a: ArrayView1<'_, f64>,
469    b: ArrayView1<'_, f64>,
470) -> f64 {
471    let n = a.len();
472    assert_eq!(g.nrows(), n);
473    assert_eq!(g.ncols(), b.len());
474    // aᵀ G b: the inner matrix–vector product G·b is the O(n²) cost and is the
475    // hot kernel of every metric inner product (g_inner / g_norm) and of the
476    // metric Gram–Schmidt tangent basis. Route it through the GPU-dispatched
477    // fast_av shim so large-ambient metrics (SPD/Stiefel/Grassmann n²×n²) offload
478    // to the GPU; the trailing a·(Gb) is an O(n) dot.
479    let gb = gam_linalg::faer_ndarray::fast_av(&g, &b);
480    dot(a, gb.view())
481}
482
483pub(crate) fn identity(n: usize) -> Array2<f64> {
484    let mut out = Array2::<f64>::zeros((n, n));
485    for i in 0..n {
486        out[[i, i]] = 1.0;
487    }
488    out
489}
490
491pub(crate) fn zero_christoffel(dim: usize) -> Vec<Array2<f64>> {
492    (0..dim).map(|_| Array2::<f64>::zeros((dim, dim))).collect()
493}
494
495pub(crate) fn wrap_angle(theta: f64) -> f64 {
496    let two_pi = std::f64::consts::PI * 2.0;
497    (theta + std::f64::consts::PI).rem_euclid(two_pi) - std::f64::consts::PI
498}
499
500pub(crate) fn sym(a: &Array2<f64>) -> Array2<f64> {
501    let mut out = a.clone();
502    for i in 0..a.nrows() {
503        for j in 0..a.ncols() {
504            out[[i, j]] = 0.5 * (a[[i, j]] + a[[j, i]]);
505        }
506    }
507    out
508}
509
510pub(crate) fn from_flat(
511    v: ArrayView1<'_, f64>,
512    rows: usize,
513    cols: usize,
514) -> GeometryResult<Array2<f64>> {
515    check_len("flat matrix", v.len(), rows * cols)?;
516    let mut out = Array2::<f64>::zeros((rows, cols));
517    for i in 0..rows {
518        for j in 0..cols {
519            out[[i, j]] = v[i * cols + j];
520        }
521    }
522    Ok(out)
523}
524
525pub(crate) fn flatten(a: &Array2<f64>) -> Array1<f64> {
526    let mut out = Array1::<f64>::zeros(a.nrows() * a.ncols());
527    for i in 0..a.nrows() {
528        for j in 0..a.ncols() {
529            out[i * a.ncols() + j] = a[[i, j]];
530        }
531    }
532    out
533}
534
535/// Build a **Euclidean-orthonormal** basis of the tangent space at `point` by
536/// modified Gram–Schmidt over the projected ambient standard basis.
537///
538/// The returned columns satisfy `Qᵀ Q = I` under the *ambient Euclidean* inner
539/// product (the plain `dot`). This is the correct, intended basis for a
540/// manifold whose Riemannian metric *is* the embedded Euclidean metric on its
541/// horizontal tangent space — notably the **Grassmann** manifold, where the
542/// tangent inner product is `tr(Δ₁ᵀΔ₂)`.
543///
544/// It is **not** metric-orthonormal for a manifold with a non-Euclidean metric
545/// (Stiefel's canonical metric `⟨Δ₁,Δ₂⟩ = tr(Δ₁ᵀ(I−½YYᵀ)Δ₂)`, or SPD's
546/// affine-invariant metric): for those, use
547/// [`tangent_basis_metric_orthonormal`], which Gram–Schmidts under the
548/// manifold's own `metric_tensor`.
549///
550/// This is the shared engine behind [`tangent_basis`](RiemannianManifold::tangent_basis)
551/// for the matrix manifolds whose tangent space has no closed-form basis. It
552/// walks the `n × k` standard basis in column-major order (outer `col`, inner
553/// `row`), projects each `e_{row,col}` onto the tangent space via
554/// `m.project_tangent`, re-orthogonalizes against the columns accepted so far,
555/// and keeps it iff its residual norm exceeds the `1e-10` drop tolerance,
556/// stopping the moment `m.dim()` independent directions have been collected.
557/// Each caller keeps its own input validation and then delegates here, so the
558/// numerically delicate orthogonalization order, drop tolerance, and early-exit
559/// logic live in exactly one place.
560pub(crate) fn projected_standard_basis_tangent<M: RiemannianManifold + ?Sized>(
561    m: &M,
562    point: ArrayView1<'_, f64>,
563    n: usize,
564    k: usize,
565) -> GeometryResult<Array2<f64>> {
566    let mut columns: Vec<Array1<f64>> = Vec::with_capacity(m.dim());
567    for col in 0..k {
568        for row in 0..n {
569            let mut e = Array2::<f64>::zeros((n, k));
570            e[[row, col]] = 1.0;
571            let mut v = m.project_tangent(point, flatten(&e).view())?;
572            for q in &columns {
573                let proj = dot(q.view(), v.view());
574                v -= &(q * proj);
575            }
576            let nrm = dot(v.view(), v.view()).sqrt();
577            if nrm > 1.0e-10 {
578                columns.push(v / nrm);
579            }
580            if columns.len() == m.dim() {
581                let mut out = Array2::<f64>::zeros((m.ambient_dim(), m.dim()));
582                for j in 0..columns.len() {
583                    for i in 0..m.ambient_dim() {
584                        out[[i, j]] = columns[j][i];
585                    }
586                }
587                return Ok(out);
588            }
589        }
590    }
591    Ok(Array2::<f64>::zeros((m.ambient_dim(), columns.len())))
592}
593
594/// Build a **metric-orthonormal** basis of the tangent space at `point`, i.e. a
595/// set of columns `Q` satisfying `Qᵀ W Q = I` where `W = m.metric_tensor(point)`
596/// is the manifold's Riemannian metric in flattened ambient coordinates.
597///
598/// This is the correct tangent basis for a manifold whose metric is **not** the
599/// embedded Euclidean inner product — Stiefel's canonical metric
600/// `⟨Δ₁,Δ₂⟩ = tr(Δ₁ᵀ(I−½YYᵀ)Δ₂)` and SPD's affine-invariant metric. (For a
601/// Euclidean-metric manifold like Grassmann, `W = I` and this coincides with
602/// [`projected_standard_basis_tangent`].)
603///
604/// Same projected-standard-basis walk as the Euclidean routine, but every inner
605/// product is the metric inner product `⟨u,v⟩_W = uᵀ W v` (via
606/// [`quad_form`]): Gram–Schmidt projections subtract `⟨q,v⟩_W · q` and the
607/// retained columns are normalized by `‖v‖_W = sqrt(⟨v,v⟩_W)`, so the resulting
608/// `Q` is orthonormal *in the manifold's metric*.
609///
610/// Concretely on `St(3, 2)` at `Y = [e₁, e₂]`, the vertical tangent
611/// `Δ = Y·[[0,−1],[1,0]]` has Euclidean norm² 2 but canonical-metric norm² 1, so
612/// a metric-orthonormal basis must reflect that — the Euclidean routine would
613/// mis-scale it.
614pub(crate) fn tangent_basis_metric_orthonormal<M: RiemannianManifold + ?Sized>(
615    m: &M,
616    point: ArrayView1<'_, f64>,
617    n: usize,
618    k: usize,
619) -> GeometryResult<Array2<f64>> {
620    let w = m.metric_tensor(point)?;
621    let mut columns: Vec<Array1<f64>> = Vec::with_capacity(m.dim());
622    for col in 0..k {
623        for row in 0..n {
624            let mut e = Array2::<f64>::zeros((n, k));
625            e[[row, col]] = 1.0;
626            let mut v = m.project_tangent(point, flatten(&e).view())?;
627            for q in &columns {
628                let proj = quad_form(w.view(), q.view(), v.view());
629                v -= &(q * proj);
630            }
631            let nrm = quad_form(w.view(), v.view(), v.view()).max(0.0).sqrt();
632            if nrm > 1.0e-10 {
633                columns.push(v / nrm);
634            }
635            if columns.len() == m.dim() {
636                let mut out = Array2::<f64>::zeros((m.ambient_dim(), m.dim()));
637                for j in 0..columns.len() {
638                    for i in 0..m.ambient_dim() {
639                        out[[i, j]] = columns[j][i];
640                    }
641                }
642                return Ok(out);
643            }
644        }
645    }
646    Ok(Array2::<f64>::zeros((m.ambient_dim(), columns.len())))
647}
648
649/// Thin/compact Gram–Schmidt QR factorization `A = Q·R` for an `n×k` input
650/// (`n ≥ k`). The returned `Q` is `n×k` with **orthonormal columns**
651/// (`QᵀQ = I`) and `R` is `k×k` upper-triangular.
652///
653/// On a rank-deficient column (residual ≈ 0 after orthogonalizing against the
654/// previously accepted columns) the diagonal `R[j, j]` is set to 0 and a
655/// *fallback* unit column is synthesized so the column count stays `k` and `Q`
656/// remains a valid orthonormal frame. The fallback is a standard axis `e_a`
657/// Gram–Schmidted against ALL previously accepted columns and renormalized; if
658/// that residual also vanishes (the axis lies in the accepted span) the next
659/// axis is tried, until an axis with a nonzero orthogonal residual is found.
660/// Simply planting `e_j` (the old behavior) breaks orthonormality — e.g. two
661/// identical columns `(1,1)/√2` would yield a fallback `e₂` with
662/// `q₁·q₂ = 1/√2 ≠ 0`.
663pub(crate) fn qr_thin(a: &Array2<f64>) -> (Array2<f64>, Array2<f64>) {
664    let n = a.nrows();
665    let k = a.ncols();
666    let mut q = Array2::<f64>::zeros((n, k));
667    let mut r = Array2::<f64>::zeros((k, k));
668    for j in 0..k {
669        let mut v = a.column(j).to_owned();
670        for i in 0..j {
671            let qi = q.column(i);
672            let rij = dot(qi, v.view());
673            r[[i, j]] = rij;
674            for row in 0..n {
675                v[row] -= rij * q[[row, i]];
676            }
677        }
678        let nrm = norm(v.view());
679        if nrm > GEOMETRY_EPS {
680            r[[j, j]] = nrm;
681            for row in 0..n {
682                q[[row, j]] = v[row] / nrm;
683            }
684        } else {
685            // Rank-deficient column: `R[j, j] = 0`. Synthesize a fallback unit
686            // column orthogonal to ALL accepted columns 0..j by Gram–Schmidting
687            // a standard axis against them; try successive axes until one has a
688            // nonzero orthogonal residual (always succeeds for j < n since the
689            // accepted columns span a j-dimensional subspace of ℝⁿ, leaving an
690            // (n−j)-dimensional orthogonal complement that at least one axis
691            // touches).
692            r[[j, j]] = 0.0;
693            for axis in 0..n {
694                let mut f = Array1::<f64>::zeros(n);
695                f[axis] = 1.0;
696                for i in 0..j {
697                    let qi = q.column(i);
698                    let proj = dot(qi, f.view());
699                    for row in 0..n {
700                        f[row] -= proj * q[[row, i]];
701                    }
702                }
703                let fnrm = norm(f.view());
704                if fnrm > GEOMETRY_EPS {
705                    for row in 0..n {
706                        q[[row, j]] = f[row] / fnrm;
707                    }
708                    break;
709                }
710            }
711        }
712    }
713    (q, r)
714}
715
716pub(crate) fn inverse(a: &Array2<f64>) -> GeometryResult<Array2<f64>> {
717    let n = a.nrows();
718    if n != a.ncols() {
719        return Err(GeometryError::Singular("inverse requires a square matrix"));
720    }
721    let mut aug = Array2::<f64>::zeros((n, 2 * n));
722    for i in 0..n {
723        for j in 0..n {
724            aug[[i, j]] = a[[i, j]];
725        }
726        aug[[i, n + i]] = 1.0;
727    }
728    for col in 0..n {
729        let mut pivot = col;
730        let mut best = aug[[col, col]].abs();
731        for row in col + 1..n {
732            let val = aug[[row, col]].abs();
733            if val > best {
734                best = val;
735                pivot = row;
736            }
737        }
738        if best < GEOMETRY_EPS {
739            return Err(GeometryError::Singular("matrix inverse pivot underflow"));
740        }
741        if pivot != col {
742            for j in 0..2 * n {
743                let tmp = aug[[col, j]];
744                aug[[col, j]] = aug[[pivot, j]];
745                aug[[pivot, j]] = tmp;
746            }
747        }
748        let scale = aug[[col, col]];
749        for j in 0..2 * n {
750            aug[[col, j]] /= scale;
751        }
752        for row in 0..n {
753            if row == col {
754                continue;
755            }
756            let factor = aug[[row, col]];
757            for j in 0..2 * n {
758                aug[[row, j]] -= factor * aug[[col, j]];
759            }
760        }
761    }
762    let mut out = Array2::<f64>::zeros((n, n));
763    for i in 0..n {
764        for j in 0..n {
765            out[[i, j]] = aug[[i, n + j]];
766        }
767    }
768    Ok(out)
769}
770
771/// Sweep budget multiplier for the classical Jacobi eigensolver: the iteration
772/// cap is `JACOBI_SWEEP_BUDGET · n²`. Classical (largest-off-diagonal) Jacobi
773/// converges quadratically once the off-diagonals are small, needing only a
774/// handful of full `O(n²)` sweeps; this generous multiple lets even clustered
775/// spectra finish while still failing loudly on a genuinely stalled matrix.
776const JACOBI_SWEEP_BUDGET: usize = 64;
777
778/// Relative off-diagonal convergence threshold for [`jacobi_symmetric`]: the
779/// largest off-diagonal magnitude must fall below `JACOBI_REL_TOL · ‖A‖_F`. Near
780/// `f64` precision so the diagonalization is accurate to working precision.
781const JACOBI_REL_TOL: f64 = 1.0e-13;
782
783pub(crate) fn jacobi_symmetric(a: &Array2<f64>) -> GeometryResult<(Array1<f64>, Array2<f64>)> {
784    let n = a.nrows();
785    if n != a.ncols() {
786        return Err(GeometryError::InvalidPoint(
787            "Jacobi eigensolver requires square input",
788        ));
789    }
790    let mut d = sym(a);
791    let mut v = identity(n);
792    let max_iter = JACOBI_SWEEP_BUDGET * n.max(1) * n.max(1);
793    // Relative convergence threshold: the largest off-diagonal magnitude must
794    // fall to `1e-13 * ||A||_F`. A fixed absolute `1e-13` is meaningless for
795    // matrices whose scale is far from unity (a well-scaled large-norm matrix
796    // could never reach it; a tiny-norm matrix would "converge" trivially),
797    // and silently returning the partially-diagonalized state after exhausting
798    // `max_iter` hides genuine non-convergence (e.g. clustered/degenerate
799    // spectra that stall the classical sweep). The Frobenius norm is invariant
800    // under the orthogonal Jacobi rotations, so it is computed once from the
801    // symmetrized input.
802    let frob_norm = {
803        let mut acc = 0.0;
804        for i in 0..n {
805            for j in 0..n {
806                acc += d[[i, j]] * d[[i, j]];
807            }
808        }
809        acc.sqrt()
810    };
811    let threshold = JACOBI_REL_TOL * frob_norm;
812    let mut converged = false;
813    for _ in 0..max_iter {
814        let mut p = 0usize;
815        let mut q = 0usize;
816        let mut best = 0.0;
817        for i in 0..n {
818            for j in i + 1..n {
819                let val = d[[i, j]].abs();
820                if val > best {
821                    best = val;
822                    p = i;
823                    q = j;
824                }
825            }
826        }
827        // `best <= threshold` (rather than `<`) makes the exactly-diagonal and
828        // zero-norm cases (`best == threshold == 0`) converge immediately.
829        if best <= threshold {
830            converged = true;
831            break;
832        }
833        let tau = (d[[q, q]] - d[[p, p]]) / (2.0 * d[[p, q]]);
834        let t = tau.signum() / (tau.abs() + (1.0 + tau * tau).sqrt());
835        let c = 1.0 / (1.0 + t * t).sqrt();
836        let s = t * c;
837        for k in 0..n {
838            let dpk = d[[p, k]];
839            let dqk = d[[q, k]];
840            d[[p, k]] = c * dpk - s * dqk;
841            d[[q, k]] = s * dpk + c * dqk;
842        }
843        for k in 0..n {
844            let dkp = d[[k, p]];
845            let dkq = d[[k, q]];
846            d[[k, p]] = c * dkp - s * dkq;
847            d[[k, q]] = s * dkp + c * dkq;
848        }
849        for k in 0..n {
850            let vkp = v[[k, p]];
851            let vkq = v[[k, q]];
852            v[[k, p]] = c * vkp - s * vkq;
853            v[[k, q]] = s * vkp + c * vkq;
854        }
855    }
856    if !converged {
857        return Err(GeometryError::Singular(
858            "Jacobi eigensolver did not converge within max_iter (off-diagonal mass above 1e-13 * Frobenius norm)",
859        ));
860    }
861    let mut evals = Array1::<f64>::zeros(n);
862    for i in 0..n {
863        evals[i] = d[[i, i]];
864    }
865    Ok((evals, v))
866}
867
868pub(crate) fn spectral_map_spd(
869    a: &Array2<f64>,
870    f: impl Fn(f64) -> GeometryResult<f64>,
871) -> GeometryResult<Array2<f64>> {
872    let (evals, evecs) = jacobi_symmetric(a)?;
873    let n = a.nrows();
874    let mut diag = Array2::<f64>::zeros((n, n));
875    for i in 0..n {
876        if evals[i] <= 0.0 || !evals[i].is_finite() {
877            return Err(GeometryError::InvalidPoint(
878                "SPD eigenvalue is not positive",
879            ));
880        }
881        diag[[i, i]] = f(evals[i])?;
882    }
883    // Reconstruction V·f(Λ)·Vᵀ: two dense n×n products GPU-dispatched via
884    // fast_ab/fast_abt for large ambient dimension.
885    use gam_linalg::faer_ndarray::{fast_ab, fast_abt};
886    Ok(fast_abt(&fast_ab(&evecs, &diag), &evecs))
887}
888
889pub(crate) fn spectral_map_symmetric(
890    a: &Array2<f64>,
891    f: impl Fn(f64) -> GeometryResult<f64>,
892) -> GeometryResult<Array2<f64>> {
893    let (evals, evecs) = jacobi_symmetric(a)?;
894    let n = a.nrows();
895    let mut diag = Array2::<f64>::zeros((n, n));
896    for i in 0..n {
897        diag[[i, i]] = f(evals[i])?;
898    }
899    // Reconstruction V·f(Λ)·Vᵀ, GPU-dispatched via fast_ab/fast_abt.
900    use gam_linalg::faer_ndarray::{fast_ab, fast_abt};
901    Ok(fast_abt(&fast_ab(&evecs, &diag), &evecs))
902}
903
904/// Thin singular value decomposition of a tall matrix `Y` (`n × k`, `n ≥ k`)
905/// via the symmetric eigendecomposition of the small `k × k` Gram matrix
906/// `YᵀY = V Σ² Vᵀ`: returns `(U, σ, V)` with `Y = U diag(σ) Vᵀ`, where `U` is
907/// `n × k` with orthonormal columns spanning `range(Y)`, `σ` holds the singular
908/// values, and `V` is `k × k` orthogonal. Forming the Gram keeps the
909/// eigenproblem at the small dimension `k`; the two products that carry the
910/// large ambient dimension `n` (`YᵀY` and `U = Y V Σ⁻¹`) are GPU-dispatched.
911///
912/// A numerically-zero singular value (`σ ≤ GEOMETRY_EPS`) leaves the
913/// corresponding `U` column zero rather than dividing through, which is what the
914/// Grassmann/Stiefel geodesic needs (a zero singular value is a vanishing
915/// principal angle); a caller requiring full rank inspects `σ` itself.
916pub(crate) fn thin_svd_gram(
917    y: &Array2<f64>,
918) -> GeometryResult<(Array2<f64>, Array1<f64>, Array2<f64>)> {
919    use gam_linalg::faer_ndarray::{fast_ab, fast_atb};
920    let (n, k) = y.dim();
921    let gram = fast_atb(y, y);
922    let (evals, v) = jacobi_symmetric(&gram)?;
923    let yv = fast_ab(y, &v);
924    let mut sigma = Array1::<f64>::zeros(k);
925    let mut u = Array2::<f64>::zeros((n, k));
926    for j in 0..k {
927        sigma[j] = evals[j].max(0.0).sqrt();
928        if sigma[j] > GEOMETRY_EPS {
929            let inv_sigma = 1.0 / sigma[j];
930            for i in 0..n {
931                u[[i, j]] = yv[[i, j]] * inv_sigma;
932            }
933        }
934    }
935    Ok((u, sigma, v))
936}
937
938/// Dense matrix exponential `exp(A)` via scaling-and-squaring with a truncated
939/// Taylor series. The Frobenius norm of `A` is driven below 1/4 by repeated
940/// halving (`A → A / 2^s`), where Taylor converges rapidly and stably; the
941/// result is then squared `s` times. With the scaled norm `θ < 1/4`, the
942/// degree-12 Taylor tail is bounded by `θ^{13} / 13! · 1/(1 - θ)`; since `13! ≈
943/// 6.23e9`, this is below `4·0.25^{13}/6.23e9 ≈ 3.8e-18`, i.e. under one f64 ulp,
944/// so the fixed degree truly reaches full f64 precision (the `< 1/2` threshold
945/// previously used left a ~2e-14 tail, two orders above an ulp). This is the
946/// standard, exact algorithm; no eigendecomposition is assumed (the inputs here
947/// are the non-normal canonical-metric block matrices on Stiefel, which are
948/// skew-like but not symmetric, so `spectral_map_*` does not apply).
949pub(crate) fn matrix_exp(a: &Array2<f64>) -> GeometryResult<Array2<f64>> {
950    let n = a.nrows();
951    if n != a.ncols() {
952        return Err(GeometryError::InvalidPoint(
953            "matrix exponential requires square input",
954        ));
955    }
956    if !a.iter().all(|v| v.is_finite()) {
957        return Err(GeometryError::InvalidPoint(
958            "matrix exponential requires finite entries",
959        ));
960    }
961    // Frobenius norm; choose the squaring count so the scaled matrix has norm
962    // below 1/4, which keeps the degree-12 Taylor truncation under one f64 ulp.
963    let mut frob = 0.0;
964    for v in a.iter() {
965        frob += v * v;
966    }
967    let frob = frob.sqrt();
968    let squarings = if frob > 0.25 {
969        (frob / 0.25).log2().ceil() as i32
970    } else {
971        0
972    };
973    let scale = 2.0_f64.powi(squarings);
974    let a_scaled = a / scale;
975
976    // exp(A_scaled) = sum_{k>=0} A_scaled^k / k! by term recurrence:
977    //   term_k = term_{k-1} · A_scaled / k.
978    // Both the Taylor term recurrence and the scaling-and-squaring use dense
979    // n×n products; GPU-dispatch them via fast_ab for large blocks.
980    use gam_linalg::faer_ndarray::fast_ab;
981    let mut result = identity(n);
982    let mut term = identity(n);
983    for k in 1..=12 {
984        term = fast_ab(&term, &a_scaled) / (k as f64);
985        result = result + &term;
986    }
987    // exp(A) = exp(A_scaled)^{2^squarings}.
988    for _ in 0..squarings {
989        result = fast_ab(&result, &result);
990    }
991    Ok(result)
992}
993
994/// Principal real logarithm of a real **orthogonal** matrix `V`, returned as
995/// the skew-symmetric `S` with `exp(S) = V`.
996///
997/// Rather than reach for a general (Schur-based) matrix logarithm — which the
998/// linear-algebra backend does not expose — this exploits the structure of an
999/// orthogonal matrix. Split `V = M + K` into its symmetric and skew parts
1000///
1001/// ```text
1002///   M = ½(V + Vᵀ)   (symmetric, eigenvalues cos θⱼ ∈ [−1, 1])
1003///   K = ½(V − Vᵀ)   (skew)
1004/// ```
1005///
1006/// For an orthogonal (hence normal) `V`, `M` and `K` are both polynomials in
1007/// `V`, so they **commute** and are simultaneously block-diagonalizable. In an
1008/// eigenbasis `Q` of the symmetric `M` (which the self-adjoint eigensolver
1009/// returns), `K̃ = QᵀKQ` is block-diagonal across distinct eigenvalues of `M`.
1010/// On each 2-D rotation plane `M` has the degenerate eigenvalue `cos θ` and `K`
1011/// acts as a skew `[[0,−sin θ],[sin θ,0]]`, whose principal logarithm is the
1012/// same skew matrix scaled by `θ / sin θ`. Because `cos θ ↦ θ = arccos(cos θ)`
1013/// is single-valued on `(0, π)`, the scale `c(λ) = arccos(λ)/√(1−λ²)` is a
1014/// well-defined function of the eigenvalue `λ` of `M`, independent of the
1015/// arbitrary in-plane basis the eigensolver picks. The whole logarithm is then
1016///
1017/// ```text
1018///   S = Q · (c(λ̄ᵢⱼ) ⊙ K̃) · Qᵀ ,    λ̄ᵢⱼ = ½(λᵢ + λⱼ),
1019/// ```
1020///
1021/// the element-wise scaling being exact on-block (where `λᵢ = λⱼ`) and
1022/// multiplying a numerically-zero entry off-block (where `K̃ᵢⱼ ≈ 0` because the
1023/// blocks are decoupled). The scaling is symmetric in `(i, j)`, so `S` stays
1024/// skew.
1025///
1026/// An eigenvalue `λ → −1` is a rotation by `π`: the geodesic to that point is
1027/// not unique (it is the cut locus / beyond the injectivity radius), so the
1028/// principal logarithm does not exist. We refuse rather than return a value
1029/// that silently picks one of the two equal-length geodesics.
1030pub(crate) fn skew_log_orthogonal(v: &Array2<f64>) -> GeometryResult<Array2<f64>> {
1031    use faer::Side;
1032    use gam_linalg::faer_ndarray::{FaerEigh, fast_ab, fast_abt, fast_atb};
1033
1034    let n = v.nrows();
1035    if v.ncols() != n {
1036        return Err(GeometryError::InvalidPoint(
1037            "matrix logarithm requires a square matrix",
1038        ));
1039    }
1040    if !v.iter().all(|x| x.is_finite()) {
1041        return Err(GeometryError::InvalidPoint(
1042            "matrix logarithm requires finite entries",
1043        ));
1044    }
1045    let mut m = Array2::<f64>::zeros((n, n));
1046    let mut k = Array2::<f64>::zeros((n, n));
1047    for i in 0..n {
1048        for j in 0..n {
1049            m[[i, j]] = 0.5 * (v[[i, j]] + v[[j, i]]);
1050            k[[i, j]] = 0.5 * (v[[i, j]] - v[[j, i]]);
1051        }
1052    }
1053    let (evals, q) = m.eigh(Side::Lower).map_err(|_| {
1054        GeometryError::Singular("matrix logarithm: symmetric eigendecomposition failed")
1055    })?;
1056    // A rotation by π (eigenvalue −1 of V) is the cut locus: the logarithm is
1057    // not single-valued there. Detect it from M's spectrum directly — on such a
1058    // plane sin θ = 0 so K carries no signal and an element-wise scaling would
1059    // silently drop the π rotation.
1060    const CUT_LOCUS_EPS: f64 = 1.0e-7;
1061    if evals.iter().any(|&lam| lam <= -1.0 + CUT_LOCUS_EPS) {
1062        return Err(GeometryError::Unsupported(
1063            "matrix logarithm undefined: rotation angle at π (beyond the injectivity radius)",
1064        ));
1065    }
1066    let kt = fast_ab(&fast_atb(&q, &k), &q); // K̃ = Qᵀ K Q
1067    let mut st = Array2::<f64>::zeros((n, n));
1068    for i in 0..n {
1069        for j in 0..n {
1070            let lam = (0.5 * (evals[i] + evals[j])).clamp(-1.0, 1.0);
1071            let sin_theta = (1.0 - lam * lam).max(0.0).sqrt();
1072            // c(λ) = θ / sin θ, with the removable singularity at θ = 0
1073            // (λ = 1) taken in the limit c → 1.
1074            let scale = if sin_theta <= 1.0e-9 {
1075                1.0
1076            } else {
1077                lam.acos() / sin_theta
1078            };
1079            st[[i, j]] = scale * kt[[i, j]];
1080        }
1081    }
1082    let s = fast_abt(&fast_ab(&q, &st), &q); // Q S̃ Qᵀ
1083    // Project out the rounding-level symmetric part so the result is exactly
1084    // skew, as the logarithm of an orthogonal matrix must be.
1085    let mut out = Array2::<f64>::zeros((n, n));
1086    for i in 0..n {
1087        for j in 0..n {
1088            out[[i, j]] = 0.5 * (s[[i, j]] - s[[j, i]]);
1089        }
1090    }
1091    Ok(out)
1092}
1093
1094/// Complete the `m × p` matrix `cols` (assumed to have orthonormal columns) to
1095/// a full `m × m` orthogonal matrix `[cols | C]`, returning the completion in
1096/// place: the first `p` columns are `cols`, the remaining `m − p` are an
1097/// orthonormal basis of the orthogonal complement of `cols`'s column space.
1098///
1099/// The complement is built by Gram–Schmidt-ing the standard axes `e₀ … e_{m−1}`
1100/// (in order) against the accumulated columns, with one reorthogonalization
1101/// pass for numerical safety. Taking the axes in order means that when `cols`
1102/// is `[Iₚ; 0]` the completion is exactly `[0; I_{m−p}]`, so the assembled
1103/// matrix is the identity — the property the Stiefel logarithm relies on to
1104/// start its iteration near `I₂ₚ` for nearby frames. The result is forced into
1105/// `SO(m)` (determinant `+1`) by flipping the sign of the last completion
1106/// column when needed, so its principal logarithm is skew-symmetric.
1107pub(crate) fn orthonormal_completion(cols: &Array2<f64>) -> Array2<f64> {
1108    let m = cols.nrows();
1109    let p = cols.ncols();
1110    let mut basis = Array2::<f64>::zeros((m, m));
1111    for j in 0..p {
1112        for i in 0..m {
1113            basis[[i, j]] = cols[[i, j]];
1114        }
1115    }
1116    let mut filled = p;
1117    let mut axis = 0usize;
1118    while filled < m && axis < m {
1119        let mut f = Array1::<f64>::zeros(m);
1120        f[axis] = 1.0;
1121        // Two Gram–Schmidt passes against the columns accepted so far.
1122        for _ in 0..2 {
1123            for c in 0..filled {
1124                let col = basis.column(c);
1125                let proj = dot(col, f.view());
1126                for i in 0..m {
1127                    f[i] -= proj * basis[[i, c]];
1128                }
1129            }
1130        }
1131        let nrm = norm(f.view());
1132        if nrm > GEOMETRY_EPS {
1133            for i in 0..m {
1134                basis[[i, filled]] = f[i] / nrm;
1135            }
1136            filled += 1;
1137        }
1138        axis += 1;
1139    }
1140    // Force det = +1 so the completion lies in SO(m) and its principal log is
1141    // skew. det of an orthogonal matrix is ±1; flip the last *appended* column
1142    // if −1. When nothing was appended (`p == m`, e.g. a square input) the
1143    // input columns are returned untouched — flipping one would corrupt the
1144    // caller's frame, and a square input's orientation is the caller's to own.
1145    if filled == m && m > p && matrix_det(&basis) < 0.0 {
1146        for i in 0..m {
1147            basis[[i, m - 1]] = -basis[[i, m - 1]];
1148        }
1149    }
1150    basis
1151}
1152
1153/// Determinant via Gaussian elimination with partial pivoting. Used only for
1154/// small orientation checks (e.g. forcing a completion into `SO(n)`); not a
1155/// hot path.
1156pub(crate) fn matrix_det(a: &Array2<f64>) -> f64 {
1157    let n = a.nrows();
1158    if n == 0 || a.ncols() != n {
1159        return 1.0;
1160    }
1161    let mut lu = a.clone();
1162    let mut det = 1.0_f64;
1163    for col in 0..n {
1164        // Partial pivot.
1165        let mut pivot = col;
1166        let mut best = lu[[col, col]].abs();
1167        for r in (col + 1)..n {
1168            let v = lu[[r, col]].abs();
1169            if v > best {
1170                best = v;
1171                pivot = r;
1172            }
1173        }
1174        if best == 0.0 {
1175            return 0.0;
1176        }
1177        if pivot != col {
1178            for c in 0..n {
1179                lu.swap([col, c], [pivot, c]);
1180            }
1181            det = -det;
1182        }
1183        det *= lu[[col, col]];
1184        for r in (col + 1)..n {
1185            let factor = lu[[r, col]] / lu[[col, col]];
1186            for c in col..n {
1187                lu[[r, c]] -= factor * lu[[col, c]];
1188            }
1189        }
1190    }
1191    det
1192}
1193
1194/// Cholesky factor `L` of a symmetric positive-definite matrix (`A = L Lᵀ`).
1195///
1196/// This is a *positive-definiteness* test, not a conditioning test: a genuine
1197/// SPD matrix with tiny eigenvalues (e.g. `[[1e-16]]`) must factor
1198/// successfully. A pivot is rejected only when it is non-finite or fails to be
1199/// strictly positive *relative to the matrix scale*. The floor
1200/// `GEOMETRY_EPS · max(1, trace(A)/n)` is the ambient scale of the matrix
1201/// multiplied by the relative machine-noise tolerance, so a positive pivot that
1202/// is merely small in absolute terms (but large relative to nothing — the whole
1203/// matrix is small) passes, while a zero, negative, or numerically-noise pivot
1204/// (indefinite / singular directions) is rejected.
1205///
1206/// Callers needing a *conditioning* margin (a lower bound on the smallest
1207/// eigenvalue) must check that separately; overloading this PD test with an
1208/// absolute `GEOMETRY_EPS` floor wrongly rejected well-formed small-scale SPD
1209/// points. No current caller (only `SpdManifold::matrix`, which validates SPD
1210/// membership) depends on a conditioning margin here.
1211pub(crate) fn cholesky_spd(a: &Array2<f64>) -> GeometryResult<Array2<f64>> {
1212    let n = a.nrows();
1213    if n != a.ncols() {
1214        return Err(GeometryError::InvalidPoint(
1215            "Cholesky requires square input",
1216        ));
1217    }
1218    // Scale-relative positive-definiteness floor. `trace(A)/n` is the mean
1219    // diagonal, which equals `mean(eigenvalues)` and is therefore the natural
1220    // scale of an SPD matrix's spectrum. The acceptance floor scales WITH the
1221    // matrix (it shrinks for tiny matrices), so a uniformly small but genuine
1222    // SPD matrix like `[[1e-16]]` — scale 1e-16, floor GEOMETRY_EPS·1e-16 =
1223    // 1e-28 — passes, while a pivot that has collapsed to numerical noise
1224    // relative to the matrix's own scale (the indefinite/singular directions)
1225    // is rejected. An absolute `GEOMETRY_EPS` floor would have wrongly rejected
1226    // such tiny SPD matrices; clamping the floor up to a constant would do the
1227    // same, so we deliberately let it shrink with the spectrum.
1228    let mut trace = 0.0_f64;
1229    for i in 0..n {
1230        trace += a[[i, i]];
1231    }
1232    if !trace.is_finite() {
1233        return Err(GeometryError::InvalidPoint(
1234            "matrix is not positive definite",
1235        ));
1236    }
1237    // Reference scale of the matrix's spectrum. The acceptance floor is this
1238    // scale times the relative tolerance, so a uniformly-tiny SPD matrix (small
1239    // scale) has a correspondingly tiny floor and still factors, while a pivot
1240    // that has collapsed to noise *relative to the matrix's own scale* (the
1241    // indefinite/singular case) is rejected.
1242    let scale = (trace / n as f64).abs().max(f64::MIN_POSITIVE);
1243    let scale_eps = GEOMETRY_EPS * scale;
1244    let mut l = Array2::<f64>::zeros((n, n));
1245    for i in 0..n {
1246        for j in 0..=i {
1247            let mut sum = a[[i, j]];
1248            for k in 0..j {
1249                sum -= l[[i, k]] * l[[j, k]];
1250            }
1251            if i == j {
1252                if !sum.is_finite() || sum <= scale_eps {
1253                    return Err(GeometryError::InvalidPoint(
1254                        "matrix is not positive definite",
1255                    ));
1256                }
1257                l[[i, j]] = sum.sqrt();
1258            } else {
1259                l[[i, j]] = sum / l[[j, j]];
1260            }
1261        }
1262    }
1263    Ok(l)
1264}
1265
1266#[cfg(test)]
1267mod cholesky_tests {
1268    use super::{GeometryError, cholesky_spd};
1269    use ndarray::Array2;
1270
1271    /// A genuine SPD matrix with a uniformly tiny spectrum (`[[1e-16]]`) must
1272    /// factor: the issue is positive-definiteness, not absolute scale. The old
1273    /// absolute `GEOMETRY_EPS` floor wrongly rejected it.
1274    #[test]
1275    fn cholesky_accepts_tiny_spd() {
1276        let mut a = Array2::<f64>::zeros((1, 1));
1277        a[[0, 0]] = 1.0e-16;
1278        let l = cholesky_spd(&a).expect("tiny positive 1x1 must be SPD");
1279        assert!((l[[0, 0]] - 1.0e-8).abs() <= 1.0e-16);
1280    }
1281
1282    /// A well-scaled SPD matrix factors and reproduces `L Lᵀ = A`.
1283    #[test]
1284    fn cholesky_accepts_well_scaled_spd() {
1285        // [[4, 2], [2, 3]] is SPD (eigenvalues ≈ 5.56, 1.44).
1286        let mut a = Array2::<f64>::zeros((2, 2));
1287        a[[0, 0]] = 4.0;
1288        a[[0, 1]] = 2.0;
1289        a[[1, 0]] = 2.0;
1290        a[[1, 1]] = 3.0;
1291        let l = cholesky_spd(&a).expect("well-scaled SPD must factor");
1292        let recon = l.dot(&l.t());
1293        for i in 0..2 {
1294            for j in 0..2 {
1295                assert!(
1296                    (recon[[i, j]] - a[[i, j]]).abs() <= 1.0e-12,
1297                    "L Lᵀ != A at ({i},{j})"
1298                );
1299            }
1300        }
1301    }
1302
1303    /// A zero pivot (singular) and an indefinite matrix must be rejected as not
1304    /// positive definite — the scale-relative floor still catches the genuine
1305    /// non-PD case.
1306    #[test]
1307    fn cholesky_rejects_zero_and_indefinite() {
1308        let zero = Array2::<f64>::zeros((1, 1));
1309        match cholesky_spd(&zero) {
1310            Err(GeometryError::InvalidPoint(_)) => {}
1311            other => panic!("expected non-PD rejection of zero pivot, got {other:?}"),
1312        }
1313        // [[1, 2], [2, 1]] has eigenvalues 3 and −1 (indefinite): the Schur
1314        // complement pivot 1 − 4 = −3 is negative.
1315        let mut indef = Array2::<f64>::zeros((2, 2));
1316        indef[[0, 0]] = 1.0;
1317        indef[[0, 1]] = 2.0;
1318        indef[[1, 0]] = 2.0;
1319        indef[[1, 1]] = 1.0;
1320        match cholesky_spd(&indef) {
1321            Err(GeometryError::InvalidPoint(_)) => {}
1322            other => panic!("expected non-PD rejection of indefinite matrix, got {other:?}"),
1323        }
1324    }
1325}
1326
1327#[cfg(test)]
1328mod qr_thin_tests {
1329    use super::qr_thin;
1330    use ndarray::Array2;
1331
1332    /// Two identical columns make the second residual vanish; the fallback axis
1333    /// must be Gram–Schmidted against the first accepted column so `QᵀQ = I`.
1334    /// The old behavior planted `e₂` directly, giving `q₁·q₂ = 1/√2`.
1335    #[test]
1336    fn qr_thin_duplicated_columns_orthonormal() {
1337        let mut a = Array2::<f64>::zeros((2, 2));
1338        // Both columns = (1, 1).
1339        a[[0, 0]] = 1.0;
1340        a[[1, 0]] = 1.0;
1341        a[[0, 1]] = 1.0;
1342        a[[1, 1]] = 1.0;
1343        let (q, r) = qr_thin(&a);
1344        // Deficient second column ⇒ R[1,1] = 0.
1345        assert!(
1346            r[[1, 1]].abs() <= 1.0e-14,
1347            "deficient column must set R[1,1]=0"
1348        );
1349        let gram = q.t().dot(&q);
1350        for i in 0..2 {
1351            for j in 0..2 {
1352                let want = if i == j { 1.0 } else { 0.0 };
1353                assert!(
1354                    (gram[[i, j]] - want).abs() <= 1.0e-12,
1355                    "QᵀQ != I at ({i},{j}): got {}",
1356                    gram[[i, j]]
1357                );
1358            }
1359        }
1360    }
1361
1362    /// A full-rank input still gives `QᵀQ = I` and reconstructs `A = QR`.
1363    #[test]
1364    fn qr_thin_full_rank_reconstructs() {
1365        let mut a = Array2::<f64>::zeros((3, 2));
1366        a[[0, 0]] = 1.0;
1367        a[[1, 0]] = 1.0;
1368        a[[2, 0]] = 0.0;
1369        a[[0, 1]] = 1.0;
1370        a[[1, 1]] = 0.0;
1371        a[[2, 1]] = 1.0;
1372        let (q, r) = qr_thin(&a);
1373        let gram = q.t().dot(&q);
1374        for i in 0..2 {
1375            for j in 0..2 {
1376                let want = if i == j { 1.0 } else { 0.0 };
1377                assert!(
1378                    (gram[[i, j]] - want).abs() <= 1.0e-12,
1379                    "QᵀQ != I at ({i},{j})"
1380                );
1381            }
1382        }
1383        let recon = q.dot(&r);
1384        for i in 0..3 {
1385            for j in 0..2 {
1386                assert!(
1387                    (recon[[i, j]] - a[[i, j]]).abs() <= 1.0e-12,
1388                    "QR != A at ({i},{j})"
1389                );
1390            }
1391        }
1392    }
1393}
1394
1395#[cfg(test)]
1396mod matrix_log_tests {
1397    use super::{matrix_exp, orthonormal_completion, skew_log_orthogonal};
1398    use ndarray::Array2;
1399
1400    /// `exp(skew_log_orthogonal(V)) = V` for a block-diagonal rotation built
1401    /// from two planes — including one with angle θ > π/2, which an
1402    /// `arcsin`-only scheme (no `cos θ` disambiguation) would get wrong.
1403    #[test]
1404    fn log_then_exp_recovers_rotation() {
1405        // 5×5 orthogonal: rotation by 2.3 rad in (0,1), by 0.4 rad in (2,3),
1406        // identity on axis 4.
1407        let mut v = Array2::<f64>::zeros((5, 5));
1408        let (c0, s0) = (2.3_f64.cos(), 2.3_f64.sin());
1409        let (c1, s1) = (0.4_f64.cos(), 0.4_f64.sin());
1410        v[[0, 0]] = c0;
1411        v[[0, 1]] = -s0;
1412        v[[1, 0]] = s0;
1413        v[[1, 1]] = c0;
1414        v[[2, 2]] = c1;
1415        v[[2, 3]] = -s1;
1416        v[[3, 2]] = s1;
1417        v[[3, 3]] = c1;
1418        v[[4, 4]] = 1.0;
1419        let s = skew_log_orthogonal(&v).expect("log of rotation");
1420        // S must be skew.
1421        for i in 0..5 {
1422            for j in 0..5 {
1423                assert!(
1424                    (s[[i, j]] + s[[j, i]]).abs() < 1e-12,
1425                    "log not skew at ({i},{j})"
1426                );
1427            }
1428        }
1429        let back = matrix_exp(&s).expect("exp of skew");
1430        let mut worst = 0.0_f64;
1431        for i in 0..5 {
1432            for j in 0..5 {
1433                worst = worst.max((back[[i, j]] - v[[i, j]]).abs());
1434            }
1435        }
1436        assert!(worst < 1e-10, "exp∘log != id for rotation: {worst:.3e}");
1437    }
1438
1439    /// A rotation by exactly π (eigenvalue −1) is the cut locus: the logarithm
1440    /// is not single-valued and must be refused, not silently dropped.
1441    #[test]
1442    fn log_refuses_pi_rotation() {
1443        // Rotation by π in the (0,1) plane: diag block [[-1,0],[0,-1]].
1444        let mut v = Array2::<f64>::zeros((3, 3));
1445        v[[0, 0]] = -1.0;
1446        v[[1, 1]] = -1.0;
1447        v[[2, 2]] = 1.0;
1448        assert!(
1449            skew_log_orthogonal(&v).is_err(),
1450            "π rotation must be refused as the cut locus"
1451        );
1452    }
1453
1454    /// Completing an `m×p` orthonormal block must yield an `SO(m)` matrix whose
1455    /// first `p` columns are the input, and a square (`p==m`) input must be
1456    /// returned untouched (never sign-flipped).
1457    #[test]
1458    fn completion_is_orthogonal_and_preserves_input() {
1459        // 4×2 orthonormal block.
1460        let mut cols = Array2::<f64>::zeros((4, 2));
1461        cols[[0, 0]] = 1.0;
1462        cols[[1, 1]] = 1.0;
1463        let full = orthonormal_completion(&cols);
1464        let gram = full.t().dot(&full);
1465        for i in 0..4 {
1466            for j in 0..4 {
1467                let want = if i == j { 1.0 } else { 0.0 };
1468                assert!((gram[[i, j]] - want).abs() < 1e-12, "not orthogonal");
1469            }
1470        }
1471        for j in 0..2 {
1472            for i in 0..4 {
1473                assert!(
1474                    (full[[i, j]] - cols[[i, j]]).abs() < 1e-14,
1475                    "input column changed"
1476                );
1477            }
1478        }
1479        // Square input with det −1 must be returned verbatim (no flip).
1480        let mut sq = Array2::<f64>::zeros((2, 2));
1481        sq[[0, 0]] = 1.0;
1482        sq[[1, 1]] = -1.0; // det = −1
1483        let out = orthonormal_completion(&sq);
1484        assert!(
1485            (out[[1, 1]] + 1.0).abs() < 1e-14,
1486            "square input was modified"
1487        );
1488    }
1489}
1490
1491#[cfg(test)]
1492mod jacobi_tests {
1493    use super::{GeometryError, jacobi_symmetric};
1494    use ndarray::Array2;
1495
1496    /// A large-norm SPD matrix has off-diagonal residuals after
1497    /// diagonalization that scale with `||A||_F`, so they sit far above the
1498    /// old *absolute* `1e-13` cutoff even when the decomposition is, in fact,
1499    /// fully converged. The relative threshold (`1e-13 * ||A||_F`) recognizes
1500    /// convergence here and returns the correct spectrum instead of grinding
1501    /// through `max_iter` sweeps and silently returning a partial diagonal.
1502    #[test]
1503    fn jacobi_converges_on_large_norm_spd() {
1504        // Q diag(1e8, 2e8, 3e8) Qᵀ for an orthogonal Q built from a planar
1505        // rotation in the (0,1) plane; eigenvalues are huge so the matrix
1506        // norm is ~1e8 and any absolute 1e-13 off-diagonal test is hopeless.
1507        let theta = 0.7_f64;
1508        let (c, s) = (theta.cos(), theta.sin());
1509        let mut q = Array2::<f64>::eye(3);
1510        q[[0, 0]] = c;
1511        q[[0, 1]] = -s;
1512        q[[1, 0]] = s;
1513        q[[1, 1]] = c;
1514        let lambda = [1.0e8_f64, 2.0e8, 3.0e8];
1515        let mut diag = Array2::<f64>::zeros((3, 3));
1516        for i in 0..3 {
1517            diag[[i, i]] = lambda[i];
1518        }
1519        let a = q.dot(&diag).dot(&q.t());
1520
1521        let (evals, evecs) = jacobi_symmetric(&a).expect("large-norm SPD must converge");
1522        let mut sorted: Vec<f64> = evals.to_vec();
1523        sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
1524        for (got, want) in sorted.iter().zip(lambda.iter()) {
1525            assert!(
1526                (got - want).abs() <= 1.0e-6 * want,
1527                "eigenvalue mismatch: got {got}, want {want}"
1528            );
1529        }
1530        // V diag(evals) Vᵀ must reconstruct A (relative to its scale).
1531        let mut diag_e = Array2::<f64>::zeros((3, 3));
1532        for i in 0..3 {
1533            diag_e[[i, i]] = evals[i];
1534        }
1535        let recon = evecs.dot(&diag_e).dot(&evecs.t());
1536        for i in 0..3 {
1537            for j in 0..3 {
1538                assert!(
1539                    (recon[[i, j]] - a[[i, j]]).abs() <= 1.0e-6 * 3.0e8,
1540                    "reconstruction mismatch at ({i},{j})"
1541                );
1542            }
1543        }
1544    }
1545
1546    /// A clustered/degenerate spectrum (two coincident eigenvalues) must still
1547    /// converge and reproduce the multiplicity. This guards against the
1548    /// relative threshold being so tight that ordinary near-degenerate SPD
1549    /// inputs trip the new non-convergence error.
1550    #[test]
1551    fn jacobi_handles_clustered_spectrum() {
1552        // diag(5, 5, 1) rotated in the (0,2) plane; the degenerate pair stays
1553        // degenerate under rotation.
1554        let theta = 0.4_f64;
1555        let (c, s) = (theta.cos(), theta.sin());
1556        let mut q = Array2::<f64>::eye(3);
1557        q[[0, 0]] = c;
1558        q[[0, 2]] = -s;
1559        q[[2, 0]] = s;
1560        q[[2, 2]] = c;
1561        let lambda = [5.0_f64, 5.0, 1.0];
1562        let mut diag = Array2::<f64>::zeros((3, 3));
1563        for i in 0..3 {
1564            diag[[i, i]] = lambda[i];
1565        }
1566        let a = q.dot(&diag).dot(&q.t());
1567
1568        let (evals, evecs) = jacobi_symmetric(&a).expect("clustered SPD must converge");
1569        let mut sorted: Vec<f64> = evals.to_vec();
1570        sorted.sort_by(|x, y| x.partial_cmp(y).unwrap());
1571        assert!((sorted[0] - 1.0).abs() <= 1.0e-12);
1572        assert!((sorted[1] - 5.0).abs() <= 1.0e-12);
1573        assert!((sorted[2] - 5.0).abs() <= 1.0e-12);
1574        // Eigenvectors must remain orthonormal even across the degenerate pair.
1575        let gram = evecs.t().dot(&evecs);
1576        for i in 0..3 {
1577            for j in 0..3 {
1578                let want = if i == j { 1.0 } else { 0.0 };
1579                assert!(
1580                    (gram[[i, j]] - want).abs() <= 1.0e-12,
1581                    "eigenvectors not orthonormal at ({i},{j})"
1582                );
1583            }
1584        }
1585    }
1586
1587    /// Non-convergence must now surface as `GeometryError::Singular` instead
1588    /// of a silently-returned partial diagonal. A symmetric input carrying a
1589    /// non-finite off-diagonal can never drive the largest off-diagonal
1590    /// magnitude below `1e-13 * ||A||_F` (the norm itself is non-finite), so
1591    /// the sweep exhausts `max_iter` and the solver must error rather than
1592    /// hand back the un-diagonalized matrix's diagonal.
1593    #[test]
1594    fn jacobi_errors_on_non_convergence() {
1595        let mut a = Array2::<f64>::eye(3);
1596        a[[0, 1]] = f64::NAN;
1597        a[[1, 0]] = f64::NAN;
1598        match jacobi_symmetric(&a) {
1599            Err(GeometryError::Singular(_)) => {}
1600            other => panic!("expected Singular non-convergence error, got {other:?}"),
1601        }
1602    }
1603}