Skip to main content

gam_geometry/
response_geometry.rs

1//! User-selectable response geometries beyond Sphere and Simplex.
2//!
3//! The fit DSL exposes `response_geometry="..."`: one scalar Gaussian GAM is
4//! fitted per tangent coordinate at a fixed base point (the intrinsic Fréchet
5//! mean when none is supplied), and predictions are mapped back to the manifold
6//! by the exponential map. Sphere and Simplex have bespoke batched wrappers in
7//! their own modules; this module supplies the same `(values 2-D, base 1-D) →
8//! tangent 2-D` / `(tangent 2-D, base 1-D) → values 2-D` contract for the
9//! curved matrix manifolds whose per-point math is already wired in
10//! this crate but which were never reachable as a *fittable* response
11//! geometry: the SPD cone `Sym⁺(n)`, the Grassmannian `Gr(k, n)`, the Stiefel
12//! manifold `St(k, n)`, and the Poincaré ball `B^d_κ`.
13//!
14//! Every primitive here delegates to the canonical landed math
15//! ([`RiemannianManifold::exp_map`]/[`log_map`](RiemannianManifold::log_map) and
16//! the Poincaré [`exp_map`](crate::manifolds::poincare::exp_map)/[`log_map`](crate::manifolds::poincare::log_map));
17//! the only new code is the batched row loop, the base-point dimension wiring,
18//! and a generic Riemannian Karcher (Fréchet) mean shared by all four. There is
19//! no separate per-manifold mean: the SPD safeguarded Karcher iteration is
20//! generalised once, over the metric supplied by
21//! [`RiemannianManifold::metric_tensor`], so adding a curved response geometry
22//! is a single resolver arm.
23
24use ndarray::{Array1, Array2, ArrayView1, ArrayView2};
25use opt::{BacktrackConfig, armijo_roundoff_cushion, backtracking_line_search, constants};
26use std::{convert::Infallible, fmt};
27
28use crate::manifold::{
29    GEOMETRY_EPS, RiemannianManifold, flatten, from_flat, jacobi_symmetric, spectral_map_symmetric,
30    sym,
31};
32use crate::manifolds::constant_curvature::{ConstantCurvature, cs_stacks3, distance_kappa_jet};
33use crate::{GeometryError, GeometryResult, GrassmannManifold, SpdManifold, StiefelManifold};
34
35/// Split a parenthesised `key=value, key=value` parameter list into trimmed,
36/// lower-cased `(key, value)` pairs. An empty list is valid (`spd()`).
37fn parse_kv(inner: &str) -> Result<Vec<(String, String)>, String> {
38    let trimmed = inner.trim();
39    if trimmed.is_empty() {
40        return Ok(Vec::new());
41    }
42    let mut out = Vec::new();
43    for piece in trimmed.split(',') {
44        let piece = piece.trim();
45        if piece.is_empty() {
46            continue;
47        }
48        let (k, v) = piece
49            .split_once('=')
50            .ok_or_else(|| format!("response_geometry parameter {piece:?} must be key=value"))?;
51        out.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
52    }
53    Ok(out)
54}
55
56/// A fittable curved response geometry. Each variant carries the shape the user
57/// requested; the embedding/ambient flat dimension is fixed by that shape and
58/// is the column count of the `values` matrix the caller supplies.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub enum ResponseManifold {
61    /// Symmetric positive-definite `n×n` matrices, flattened row-major to `n²`
62    /// ambient coordinates (the layout [`SpdManifold`] uses).
63    Spd { n: usize },
64    /// `k`-dimensional subspaces of `ℝⁿ`, represented by an orthonormal `n×k`
65    /// frame flattened to `n·k` ambient coordinates.
66    Grassmann { k: usize, n: usize },
67    /// Orthonormal `k`-frames in `ℝⁿ`, flattened to `n·k` ambient coordinates.
68    Stiefel { k: usize, n: usize },
69    /// The Poincaré ball of dimension `d` with curvature `κ < 0`.
70    Poincare { dim: usize, curvature: f64 },
71    /// Constant-curvature manifold `M_κ` of dimension `d` with curvature `κ`
72    /// (any finite real value). `κ > 0` → spherical, `κ = 0` → flat (Euclidean
73    /// up to scale), `κ < 0` → hyperbolic (Poincaré ball). Unlike `Poincare`,
74    /// which fixes `κ < 0`, this variant accepts any curvature including zero
75    /// and positive values, and is the target for curvature-as-estimand fits
76    /// where `κ̂` is optimized over all of ℝ (#1104).
77    ConstantCurvature { dim: usize, kappa: f64 },
78}
79
80impl ResponseManifold {
81    /// Resolve a lower-cased geometry label and its shape parameters into a
82    /// response manifold. Shape parameters are passed positionally exactly as
83    /// the FFI marshals them; absent/zero values are rejected here so the error
84    /// surfaces at selection time rather than mid-fit.
85    ///
86    /// - `"spd"` needs `n` (matrix side).
87    /// - `"grassmann"` / `"stiefel"` need `k` and `n` with `1 ≤ k ≤ n`.
88    /// - `"poincare"` needs `dim` and a strictly negative `curvature`.
89    pub fn resolve(
90        kind: &str,
91        n: Option<usize>,
92        k: Option<usize>,
93        dim: Option<usize>,
94        curvature: Option<f64>,
95    ) -> Result<Self, String> {
96        match kind {
97            "spd" => {
98                let n = n.ok_or_else(|| "response_geometry='spd' requires n".to_string())?;
99                if n == 0 {
100                    return Err("response_geometry='spd' requires n >= 1".to_string());
101                }
102                Ok(Self::Spd { n })
103            }
104            "grassmann" => {
105                let k = k.ok_or_else(|| "response_geometry='grassmann' requires k".to_string())?;
106                let n = n.ok_or_else(|| "response_geometry='grassmann' requires n".to_string())?;
107                if k == 0 || n == 0 || k > n {
108                    return Err("response_geometry='grassmann' requires 1 <= k <= n".to_string());
109                }
110                Ok(Self::Grassmann { k, n })
111            }
112            "stiefel" => {
113                let k = k.ok_or_else(|| "response_geometry='stiefel' requires k".to_string())?;
114                let n = n.ok_or_else(|| "response_geometry='stiefel' requires n".to_string())?;
115                if k == 0 || n == 0 || k > n {
116                    return Err("response_geometry='stiefel' requires 1 <= k <= n".to_string());
117                }
118                Ok(Self::Stiefel { k, n })
119            }
120            "poincare" => {
121                let dim =
122                    dim.ok_or_else(|| "response_geometry='poincare' requires dim".to_string())?;
123                if dim == 0 {
124                    return Err("response_geometry='poincare' requires dim >= 1".to_string());
125                }
126                let curvature = curvature
127                    .ok_or_else(|| "response_geometry='poincare' requires curvature".to_string())?;
128                if !(curvature.is_finite() && curvature < 0.0) {
129                    return Err(
130                        "response_geometry='poincare' requires finite curvature < 0".to_string()
131                    );
132                }
133                Ok(Self::Poincare { dim, curvature })
134            }
135            "constant_curvature" => {
136                let dim = dim.ok_or_else(|| {
137                    "response_geometry='constant_curvature' requires dim".to_string()
138                })?;
139                if dim == 0 {
140                    return Err(
141                        "response_geometry='constant_curvature' requires dim >= 1".to_string()
142                    );
143                }
144                // curvature defaults to 0 (flat) when not supplied — the user can
145                // supply any finite value; the κ-estimand outer loop will optimize it.
146                let kappa = curvature.unwrap_or(0.0);
147                if !kappa.is_finite() {
148                    return Err(
149                        "response_geometry='constant_curvature' requires finite curvature"
150                            .to_string(),
151                    );
152                }
153                Ok(Self::ConstantCurvature { dim, kappa })
154            }
155            other => Err(format!(
156                "response_geometry must be one of 'spd', 'grassmann', 'stiefel', 'poincare', \
157                 'constant_curvature', 'spherical', or 'simplex'; got {other:?}"
158            )),
159        }
160    }
161
162    /// Parse a user-facing `response_geometry` label, magic-by-default: the head
163    /// is the geometry name, an optional parenthesised `key=value` list carries
164    /// shape parameters, and anything not given is inferred from the ambient
165    /// column count `cols` of the response matrix.
166    ///
167    /// Recognised forms (case-insensitive, whitespace tolerant):
168    /// - `"spd"` — `n = √cols` (must be a perfect square).
169    /// - `"grassmann(k=2)"` or `"grassmann(k=2,n=5)"` — `n` defaults to
170    ///   `cols / k`; `k` is required (it cannot be inferred from `n·k`).
171    /// - `"stiefel(k=2)"` / `"stiefel(k=2,n=5)"` — same inference as Grassmann.
172    /// - `"poincare"` or `"poincare(curvature=-0.5)"` — `dim = cols`; curvature
173    ///   defaults to `-1.0`.
174    ///
175    /// This is the single mapping from the formula-DSL string to a constructed
176    /// response manifold; the FFI passes the raw label straight through.
177    pub fn parse(label: &str, cols: usize) -> Result<Self, String> {
178        let lowered = label.trim().to_ascii_lowercase();
179        let (head, params) = match lowered.split_once('(') {
180            Some((h, rest)) => {
181                let rest = rest.trim_end();
182                let inner = rest
183                    .strip_suffix(')')
184                    .ok_or_else(|| format!("response_geometry {label:?}: missing closing ')'"))?;
185                (h.trim().to_string(), parse_kv(inner)?)
186            }
187            None => (lowered.clone(), Vec::new()),
188        };
189        let get_usize = |key: &str| -> Result<Option<usize>, String> {
190            for (k, v) in &params {
191                if k == key {
192                    let parsed: usize = v.parse().map_err(|_| {
193                        format!("response_geometry {label:?}: {key} must be a non-negative integer")
194                    })?;
195                    return Ok(Some(parsed));
196                }
197            }
198            Ok(None)
199        };
200        let get_f64 = |key: &str| -> Result<Option<f64>, String> {
201            for (k, v) in &params {
202                if k == key {
203                    let parsed: f64 = v.parse().map_err(|_| {
204                        format!("response_geometry {label:?}: {key} must be a real number")
205                    })?;
206                    return Ok(Some(parsed));
207                }
208            }
209            Ok(None)
210        };
211
212        match head.as_str() {
213            "spd" => {
214                let n = match get_usize("n")? {
215                    Some(n) => n,
216                    None => {
217                        let r = (cols as f64).sqrt().round() as usize;
218                        if r * r != cols {
219                            return Err(format!(
220                                "response_geometry='spd': {cols} response columns is not a perfect \
221                                 square; pass spd(n=...) explicitly"
222                            ));
223                        }
224                        r
225                    }
226                };
227                Self::resolve("spd", Some(n), None, None, None)
228            }
229            "grassmann" | "stiefel" => {
230                let k = get_usize("k")?.ok_or_else(|| {
231                    format!("response_geometry='{head}' requires k, e.g. {head}(k=2)")
232                })?;
233                let n = match get_usize("n")? {
234                    Some(n) => n,
235                    None => {
236                        if k == 0 || cols % k != 0 {
237                            return Err(format!(
238                                "response_geometry='{head}': {cols} response columns is not \
239                                 divisible by k={k}; pass {head}(k=..,n=..) explicitly"
240                            ));
241                        }
242                        cols / k
243                    }
244                };
245                Self::resolve(&head, Some(n), Some(k), None, None)
246            }
247            "poincare" => {
248                let dim = get_usize("dim")?.unwrap_or(cols);
249                let curvature = get_f64("curvature")?.unwrap_or(-1.0);
250                Self::resolve("poincare", None, None, Some(dim), Some(curvature))
251            }
252            "constant_curvature" => {
253                let dim = get_usize("dim")?.unwrap_or(cols);
254                // κ defaults to 0 (flat initial point for the REML optimizer).
255                let kappa = get_f64("kappa")?
256                    .or_else(|| get_f64("curvature").ok().flatten())
257                    .unwrap_or(0.0);
258                Self::resolve("constant_curvature", None, None, Some(dim), Some(kappa))
259            }
260            other => Err(format!(
261                "response_geometry must be one of 'spd', 'grassmann(k=..)', 'stiefel(k=..)', \
262                 'poincare', 'constant_curvature', 'spherical', or 'simplex'; got {other:?}"
263            )),
264        }
265    }
266
267    /// Canonical, fully-specified label echoed back to the caller (mirrors the
268    /// way the sphere/simplex dispatch reports its resolved coordinate label).
269    pub fn canonical_label(&self) -> String {
270        match self {
271            Self::Spd { n } => format!("spd(n={n})"),
272            Self::Grassmann { k, n } => format!("grassmann(k={k},n={n})"),
273            Self::Stiefel { k, n } => format!("stiefel(k={k},n={n})"),
274            Self::Poincare { dim, curvature } => {
275                format!("poincare(dim={dim},curvature={curvature})")
276            }
277            Self::ConstantCurvature { dim, kappa } => {
278                format!("constant_curvature(dim={dim},kappa={kappa})")
279            }
280        }
281    }
282
283    /// Ambient (flattened) coordinate count: the column width of the `values`
284    /// matrix and the `base` vector.
285    pub fn ambient_dim(&self) -> usize {
286        match self {
287            Self::Spd { n } => n * n,
288            Self::Grassmann { k, n } | Self::Stiefel { k, n } => n * k,
289            Self::Poincare { dim, .. } | Self::ConstantCurvature { dim, .. } => *dim,
290        }
291    }
292
293    /// Radius of a geodesic support ball that certifies a stationary Karcher
294    /// point as the unique global Fréchet mean. `None` denotes a Hadamard
295    /// geometry, where squared distance is globally geodesically convex and no
296    /// finite support-radius gate is needed.
297    ///
298    /// The positive-curvature radii are the conservative strong-convexity bound
299    /// `½ min(inj_lower, π/(2√K_max))`, specialized to each canonical metric:
300    /// `K_max=1` for projective/spherical `k=1`, `K_max=2` for Grassmann,
301    /// `K_max=5/4` for canonical Stiefel, and `K_max=κ` for a spherical
302    /// constant-curvature response. These are geometric invariants, not solver
303    /// tuning knobs.
304    fn frechet_uniqueness_radius(&self) -> Option<f64> {
305        match self {
306            Self::Spd { .. } | Self::Poincare { .. } => None,
307            Self::Grassmann { k: 1, .. } | Self::Stiefel { k: 1, .. } => {
308                Some(std::f64::consts::FRAC_PI_4)
309            }
310            Self::Grassmann { .. } => Some(std::f64::consts::PI / (4.0 * 2.0_f64.sqrt())),
311            Self::Stiefel { .. } => Some(std::f64::consts::PI / (2.0 * 5.0_f64.sqrt())),
312            Self::ConstantCurvature { kappa, .. } if *kappa > 0.0 => {
313                Some(std::f64::consts::PI / (4.0 * kappa.sqrt()))
314            }
315            Self::ConstantCurvature { .. } => None,
316        }
317    }
318
319    /// Build the underlying [`RiemannianManifold`] for the matrix geometries.
320    /// `None` for Poincaré, whose primitives are free functions parameterised
321    /// by curvature rather than a trait object.
322    fn riemannian(&self) -> Option<Box<dyn RiemannianManifold>> {
323        match self {
324            Self::Spd { n } => Some(Box::new(SpdManifold::new(*n))),
325            Self::Grassmann { k, n } => GrassmannManifold::new(*k, *n)
326                .ok()
327                .map(|m| Box::new(m) as _),
328            Self::Stiefel { k, n } => StiefelManifold::new(*k, *n).ok().map(|m| Box::new(m) as _),
329            Self::ConstantCurvature { dim, kappa } => {
330                Some(Box::new(ConstantCurvature::new(*dim, *kappa)))
331            }
332            Self::Poincare { .. } => None,
333        }
334    }
335
336    /// Per-point logarithm `log_base(value)` in flat ambient coordinates.
337    fn log_point(
338        &self,
339        base: ArrayView1<'_, f64>,
340        value: ArrayView1<'_, f64>,
341    ) -> GeometryResult<Array1<f64>> {
342        match self {
343            Self::Poincare { curvature, .. } => {
344                crate::manifolds::poincare::log_map(base, value, *curvature)
345            }
346            // #2351: the constant-curvature response chart identifies its
347            // origin with the base point, so the logarithm evaluates in the
348            // base-centred frame: log_0(value − base). At the origin the
349            // Möbius denominator is identically 1, killing the off-origin
350            // κ>0 antipodal singularity that crashed prediction for
351            // ordinary sphere-patch data. This matches the criterion, which
352            // scores the same centred coordinates.
353            Self::ConstantCurvature { dim, kappa } => {
354                let chart = ConstantCurvature::new(*dim, *kappa);
355                let origin = Array1::<f64>::zeros(*dim);
356                let centred = &value.to_owned() - &base;
357                chart.log_map(origin.view(), centred.view())
358            }
359            Self::Spd { .. } | Self::Grassmann { .. } | Self::Stiefel { .. } => self
360                .riemannian()
361                .expect("riemannian response manifold")
362                .log_map(base, value),
363        }
364    }
365
366    /// Per-point exponential `exp_base(tangent)` in flat ambient coordinates.
367    fn exp_point(
368        &self,
369        base: ArrayView1<'_, f64>,
370        tangent: ArrayView1<'_, f64>,
371    ) -> GeometryResult<Array1<f64>> {
372        match self {
373            Self::Poincare { curvature, .. } => {
374                crate::manifolds::poincare::exp_map(base, tangent, *curvature)
375            }
376            // #2351: exact inverse of the centred logarithm above —
377            // exp_0(tangent) + base. Round-trips exactly with log_point.
378            Self::ConstantCurvature { dim, kappa } => {
379                let chart = ConstantCurvature::new(*dim, *kappa);
380                let origin = Array1::<f64>::zeros(*dim);
381                let centred = chart.exp_map(origin.view(), tangent)?;
382                Ok(centred + &base)
383            }
384            Self::Spd { .. } | Self::Grassmann { .. } | Self::Stiefel { .. } => self
385                .riemannian()
386                .expect("riemannian response manifold")
387                .exp_map(base, tangent),
388        }
389    }
390
391    /// Euclidean / Frobenius distance from an arbitrary ambient row to the
392    /// candidate response geometry, in flat ambient coordinates — the extrinsic
393    /// constraint-violation distance behind [`response_projection_residual`].
394    ///
395    /// Unlike [`log_point`](Self::log_point), which is gatekept to *genuine*
396    /// manifold points on both arguments, this accepts off-manifold `value`. The
397    /// distance is computed in closed form per geometry and is **well-defined for
398    /// every input** — there is no rank-deficiency error path, because the
399    /// distance to a set is defined even where the nearest point is not unique:
400    ///
401    /// * `Gr(k, n)` / `St(k, n)` — distance to the orthonormal-frame set,
402    ///   `√Σ_i (σ_i − 1)²` with `σ_i = √max(λ_i(YᵀY), 0)` the singular values of
403    ///   the `n × k` frame `Y`. Exact for every rank (`σ_i = 0` columns
404    ///   contribute `1` each). Grassmann and Stiefel coincide because this module
405    ///   represents Grassmann points by frames — it is a *representation*
406    ///   distance, not a subspace/principal-angle distance.
407    /// * SPD cone — distance to the *closed* PSD cone,
408    ///   `√(‖skew(A)‖_F² + Σ_{λ_i<0} λ_i²)` with `λ_i` the eigenvalues of the
409    ///   symmetric part `sym(A)`. This is the infimum distance to the open SPD
410    ///   cone; a zero distance means PSD, **not** strictly PD.
411    /// * Poincaré ball — distance to the *manifold* open ball of radius
412    ///   `R = 1/√(−c)`: `max(0, ‖x‖ − R)`. (This uses the true radius `R`, not
413    ///   the slightly smaller numerical safety radius used when projecting points
414    ///   for a fit, so interior points score exactly zero.)
415    /// * `ConstantCurvature` — distance to the chart *domain*: `0` for `κ ≥ 0`
416    ///   (chart is all of `ℝ^d`), else `max(0, ‖x‖ − 1/√(−κ))`. The curvature
417    ///   lives in the metric, not the domain, so this is a domain-admissibility
418    ///   check only and carries little curvature information.
419    fn manifold_residual(&self, value: ArrayView1<'_, f64>) -> GeometryResult<f64> {
420        match self {
421            Self::Poincare { curvature, .. } => ball_domain_residual(value, *curvature),
422            Self::ConstantCurvature { kappa, .. } => {
423                if *kappa >= 0.0 {
424                    Ok(0.0)
425                } else {
426                    ball_domain_residual(value, *kappa)
427                }
428            }
429            Self::Spd { n } => {
430                let mat = from_flat(value, *n, *n)?;
431                let symm = sym(&mat);
432                let psd = spectral_map_symmetric(&symm, |lam| Ok(lam.max(0.0)))?;
433                // Distance to the closed PSD cone, measured against the original
434                // (skew included) input so the skew-symmetric part is counted.
435                Ok(frobenius_distance(value, flatten(&psd).view()))
436            }
437            Self::Grassmann { k, n } | Self::Stiefel { k, n } => {
438                use gam_linalg::faer_ndarray::fast_atb;
439                let frame = from_flat(value, *n, *k)?;
440                let gram = fast_atb(&frame, &frame);
441                let (evals, _) = jacobi_symmetric(&gram)?;
442                let mut sq = 0.0_f64;
443                for &lam in evals.iter() {
444                    let sigma = lam.max(0.0).sqrt();
445                    let d = sigma - 1.0;
446                    sq += d * d;
447                }
448                Ok(sq.sqrt())
449            }
450        }
451    }
452
453    /// Squared metric norm `‖v‖²_base` of a tangent at `base`. Used by the
454    /// Karcher iteration's stationarity test. Poincaré uses the conformal
455    /// factor squared; the matrix manifolds and ConstantCurvature use the trait
456    /// metric tensor.
457    fn sq_metric_norm(
458        &self,
459        base: ArrayView1<'_, f64>,
460        v: ArrayView1<'_, f64>,
461    ) -> GeometryResult<f64> {
462        match self {
463            Self::Poincare { curvature, .. } => {
464                let lam = crate::manifolds::poincare::conformal_factor(base, *curvature)?;
465                Ok(lam * lam * v.iter().map(|x| x * x).sum::<f64>())
466            }
467            Self::ConstantCurvature { .. }
468            | Self::Spd { .. }
469            | Self::Grassmann { .. }
470            | Self::Stiefel { .. } => {
471                let g = self
472                    .riemannian()
473                    .expect("riemannian response manifold")
474                    .metric_tensor(base)?;
475                let gv = g.dot(&v);
476                Ok(v.dot(&gv).max(0.0))
477            }
478        }
479    }
480}
481
482/// Batched response-geometry logarithm: map every manifold-valued response row
483/// to its tangent coordinate at `base`. `values` is `(n_rows, ambient)`, `base`
484/// is `(ambient,)`, and the returned tangent is `(n_rows, ambient)` (the same
485/// flat ambient layout — the tangent of a matrix manifold is itself a flattened
486/// matrix). The scalar Gaussian GAMs the caller fits operate column-wise on
487/// this matrix exactly as they do for the sphere.
488pub fn response_log_map(
489    manifold: ResponseManifold,
490    values: ArrayView2<'_, f64>,
491    base: ArrayView1<'_, f64>,
492) -> Result<Array2<f64>, String> {
493    let ambient = manifold.ambient_dim();
494    let (n_rows, cols) = values.dim();
495    if base.len() != ambient {
496        return Err(format!(
497            "response geometry base point has length {}; expected {ambient}",
498            base.len()
499        ));
500    }
501    if cols != ambient {
502        return Err(format!(
503            "response geometry values have {cols} columns; expected {ambient}"
504        ));
505    }
506    let mut out = Array2::<f64>::zeros((n_rows, ambient));
507    for row in 0..n_rows {
508        let tangent = manifold
509            .log_point(base, values.row(row))
510            .map_err(|e| format!("response geometry log map (row {row}): {e}"))?;
511        out.row_mut(row).assign(&tangent);
512    }
513    Ok(out)
514}
515
516/// Batched response-geometry exponential: map predicted tangent coordinates
517/// back to manifold-valued responses at `base`. Inverse of [`response_log_map`]
518/// with the same shapes.
519pub fn response_exp_map(
520    manifold: ResponseManifold,
521    tangent: ArrayView2<'_, f64>,
522    base: ArrayView1<'_, f64>,
523) -> Result<Array2<f64>, String> {
524    let ambient = manifold.ambient_dim();
525    let (n_rows, cols) = tangent.dim();
526    if base.len() != ambient {
527        return Err(format!(
528            "response geometry base point has length {}; expected {ambient}",
529            base.len()
530        ));
531    }
532    if cols != ambient {
533        return Err(format!(
534            "response geometry tangent has {cols} columns; expected {ambient}"
535        ));
536    }
537    if !tangent.iter().all(|v| v.is_finite()) {
538        return Err("response geometry tangent must contain only finite values".to_string());
539    }
540    let mut out = Array2::<f64>::zeros((n_rows, ambient));
541    for row in 0..n_rows {
542        let value = manifold
543            .exp_point(base, tangent.row(row))
544            .map_err(|e| format!("response geometry exp map (row {row}): {e}"))?;
545        out.row_mut(row).assign(&value);
546    }
547    Ok(out)
548}
549
550/// Numerically-stable Euclidean norm `‖v‖₂`, scaled by the largest-magnitude
551/// entry so the squared sum cannot overflow for large but finite inputs.
552fn scaled_l2_norm(v: ArrayView1<'_, f64>) -> f64 {
553    let mut scale = 0.0_f64;
554    for &x in v.iter() {
555        let a = x.abs();
556        if a > scale {
557            scale = a;
558        }
559    }
560    if scale == 0.0 {
561        return 0.0;
562    }
563    let mut ssq = 0.0_f64;
564    for &x in v.iter() {
565        let t = x / scale;
566        ssq += t * t;
567    }
568    scale * ssq.sqrt()
569}
570
571/// Numerically-stable Frobenius distance `‖a − b‖₂` over equal-length flat
572/// vectors, scaled by the largest entrywise difference to avoid overflow.
573fn frobenius_distance(a: ArrayView1<'_, f64>, b: ArrayView1<'_, f64>) -> f64 {
574    let mut scale = 0.0_f64;
575    for (x, y) in a.iter().zip(b.iter()) {
576        let d = (x - y).abs();
577        if d > scale {
578            scale = d;
579        }
580    }
581    if scale == 0.0 {
582        return 0.0;
583    }
584    let mut ssq = 0.0_f64;
585    for (x, y) in a.iter().zip(b.iter()) {
586        let t = (x - y) / scale;
587        ssq += t * t;
588    }
589    scale * ssq.sqrt()
590}
591
592/// Distance from `value` to the open ball of radius `R = 1/√(−c)` (`c < 0`):
593/// `max(0, ‖value‖ − R)`, the true Euclidean infimum distance to the ball.
594/// Errors if the curvature is not a finite negative number.
595fn ball_domain_residual(value: ArrayView1<'_, f64>, curvature: f64) -> GeometryResult<f64> {
596    if !curvature.is_finite() || curvature >= 0.0 {
597        return Err(GeometryError::InvalidPoint(
598            "ball distance requires a finite negative curvature",
599        ));
600    }
601    let radius = (-curvature).sqrt().recip();
602    Ok((scaled_l2_norm(value) - radius).max(0.0))
603}
604
605/// Per-row extrinsic distance from ambient observations to a *candidate*
606/// response geometry — a coordinate-dependent constraint / closure-distance
607/// diagnostic.
608///
609/// What this is (and is not)
610/// -------------------------
611/// This is a cheap, pre-fit **constraint-violation** measure: given a candidate
612/// response geometry, how far does each raw row sit from that geometry's
613/// extrinsic representation (the unit-norm frame, the PSD cone, the Poincaré
614/// ball)? It is **not** the post-fit on/off-manifold membership signal (which
615/// comes from a fitted geometric smooth's residual and posterior predictive
616/// density), and it is **not** a universal cross-geometry model-selection score:
617/// it measures extrinsic constraint violation *in a chosen coordinate chart*,
618/// not intrinsic topology or curvature. Different candidate geometries have
619/// different chart codimensions (a full-dimensional Poincaré/`κ ≥ 0` chart can
620/// score zero trivially), so residuals are not directly comparable across
621/// candidates without a noise model and per-candidate calibration. Use it as a
622/// fast per-candidate gate, with candidate-specific thresholds.
623///
624/// What it computes
625/// ----------------
626/// For each ambient row `x`, `manifold_residual`
627/// returns the closed-form distance to the candidate geometry (well-defined for
628/// every input and every rank — see that method for the per-geometry formulas),
629/// and this returns:
630///
631/// * `residual[i]` — the absolute distance-to-geometry (zero for genuinely
632///   admissible rows; for the matrix manifolds, exact to machine precision).
633/// * `relative[i] = residual[i] / (‖x‖ + eps)` — the distance normalised by the
634///   row's ambient magnitude. **Note:** this is dimensionless but *not*
635///   scale-invariant for the fixed-radius geometries (Stiefel/Grassmann/ball)
636///   and is *not* bounded by `1` (it diverges as `‖x‖ → 0`); it is scale-free
637///   only for the homogeneous SPD cone. Treat it as `input_norm_relative`, not
638///   an off-manifold fraction.
639///
640/// Unlike [`response_log_map`], **no base point is needed**. `values` is
641/// `(n_rows, ambient)`; both returned arrays are `(n_rows,)`. Every fittable
642/// response geometry — including `ConstantCurvature` — has a closed-form
643/// distance, so no variant errors on a valid, finite input.
644pub fn response_projection_residual(
645    manifold: ResponseManifold,
646    values: ArrayView2<'_, f64>,
647) -> Result<(Array1<f64>, Array1<f64>), String> {
648    let ambient = manifold.ambient_dim();
649    let (n_rows, cols) = values.dim();
650    if cols != ambient {
651        return Err(format!(
652            "response geometry values have {cols} columns; expected {ambient}"
653        ));
654    }
655    if !values.iter().all(|v| v.is_finite()) {
656        return Err("response geometry values must contain only finite values".to_string());
657    }
658
659    let mut residual = Array1::<f64>::zeros(n_rows);
660    let mut relative = Array1::<f64>::zeros(n_rows);
661    for row in 0..n_rows {
662        let value = values.row(row);
663        let dist = manifold
664            .manifold_residual(value)
665            .map_err(|e| format!("response geometry residual (row {row}): {e}"))?;
666        let rel = dist / (scaled_l2_norm(value) + GEOMETRY_EPS);
667        if !dist.is_finite() || !rel.is_finite() {
668            return Err(format!(
669                "response geometry residual (row {row}) is non-finite"
670            ));
671        }
672        residual[row] = dist;
673        relative[row] = rel;
674    }
675    Ok((residual, relative))
676}
677
678/// String-driven response-geometry log map: parse the user `label` (with shape
679/// inference from the response column count), pick the base point (intrinsic
680/// Fréchet mean when `base` is `None`), map every row to its tangent, and report
681/// the canonical resolved label. This is the curved-manifold analogue of the
682/// sphere/simplex dispatch and the single entry the FFI calls for these
683/// geometries.
684///
685/// `weights` are the per-observation prior weights used ONLY to pick the intrinsic
686/// base point (they are ignored when an explicit `base` is supplied). When the
687/// caller supplies observation weights they must reach the linearization point so
688/// the tangent chart is expanded around the *weighted* Fréchet mean — where the
689/// weighted mass lives — matching the weighted tangent regression run there
690/// (#2125). `None` recovers the uniform intrinsic mean.
691pub fn dispatch_log_map(
692    values: ArrayView2<'_, f64>,
693    label: &str,
694    base: Option<ArrayView1<'_, f64>>,
695    weights: Option<ArrayView1<'_, f64>>,
696) -> Result<(Array2<f64>, Array1<f64>, String), String> {
697    let manifold = ResponseManifold::parse(label, values.ncols())?;
698    let base_point = match base {
699        Some(b) => b.to_owned(),
700        // #2351: the constant-curvature chart identifies its origin with the
701        // FLAT centroid — the same κ-independent base the curvature criterion
702        // profiled — so the default base here must be that centroid, not the
703        // Karcher mean (which re-entangles the base with the chart scale and
704        // diverges from the point the fit's κ̂ was estimated around).
705        None => match manifold {
706            ResponseManifold::ConstantCurvature { dim, .. } => {
707                let (n_rows, _) = values.dim();
708                if n_rows == 0 {
709                    return Err(
710                        "constant-curvature log map requires at least one response row".into(),
711                    );
712                }
713                let mut centroid = Array1::<f64>::zeros(dim);
714                match weights {
715                    Some(w) => {
716                        let normalized = crate::normalize_weights(n_rows, Some(w))
717                            .map_err(|_| "constant-curvature log map has invalid weights")?;
718                        for (row, &wi) in values.outer_iter().zip(normalized.iter()) {
719                            centroid.scaled_add(wi, &row);
720                        }
721                    }
722                    None => {
723                        for row in values.outer_iter() {
724                            centroid += &row;
725                        }
726                        centroid.mapv_inplace(|v| v / n_rows as f64);
727                    }
728                }
729                centroid
730            }
731            _ => response_frechet_mean(manifold, values, weights, 1.0e-12, 256)
732                .map_err(|err| err.to_string())?,
733        },
734    };
735    let tangent = response_log_map(manifold, values, base_point.view())?;
736    Ok((tangent, base_point, manifold.canonical_label()))
737}
738
739/// String-driven response-geometry exponential map: inverse of
740/// [`dispatch_log_map`] given an explicit base point.
741pub fn dispatch_exp_map(
742    tangent: ArrayView2<'_, f64>,
743    label: &str,
744    base: ArrayView1<'_, f64>,
745) -> Result<Array2<f64>, String> {
746    let manifold = ResponseManifold::parse(label, tangent.ncols())?;
747    response_exp_map(manifold, tangent, base)
748}
749
750/// Intrinsic (Karcher) Fréchet mean of manifold-valued responses, the default
751/// base point when the user supplies none. `values` is `(n_rows, ambient)`.
752///
753/// This is the SPD safeguarded Karcher iteration generalised over an arbitrary
754/// [`ResponseManifold`]: a Riemannian gradient-descent on the weighted
755/// dispersion `V(P) = Σ_i w_i ‖log_P(X_i)‖²_P` with the descent direction
756/// `ξ = Σ_i w_i log_P(X_i)` (`= −½ grad V`), a unit Karcher step `exp_P(t·ξ)`
757/// with Armijo backtracking plus a round-off cushion, and the metric-norm
758/// stationarity certificate `‖ξ‖_P ≤ tol`. No approximate point is returned on
759/// a stalled line search or exhausted iteration budget. Positively curved
760/// geometries additionally require the weighted support to lie inside their
761/// analytic strong-convexity radius, certifying the stationary point as the
762/// unique global Fréchet mean; diffuse data return a typed error and require an
763/// explicit base instead of selecting a capped multistart basin. The SPD-specific
764/// version in [`crate::manifolds::spd::spd_frechet_mean`] remains for the affine
765/// inverse it caches per step; this generic form pays a metric-tensor solve but
766/// covers all four geometries uniformly.
767pub fn response_frechet_mean(
768    manifold: ResponseManifold,
769    values: ArrayView2<'_, f64>,
770    weights: Option<ArrayView1<'_, f64>>,
771    tol: f64,
772    max_iter: usize,
773) -> GeometryResult<Array1<f64>> {
774    let ambient = manifold.ambient_dim();
775    let (m, cols) = values.dim();
776    if m == 0 || cols != ambient {
777        return Err(GeometryError::InvalidPoint(
778            "response geometry Fréchet mean requires a non-empty value matrix with manifold ambient width",
779        ));
780    }
781    if !(tol.is_finite() && tol > 0.0) {
782        return Err(GeometryError::InvalidPoint(
783            "response geometry Fréchet mean tolerance must be finite and positive",
784        ));
785    }
786    let w = crate::normalize_weights(m, weights).map_err(|_| {
787        GeometryError::InvalidPoint("response geometry Fréchet mean has invalid weights")
788    })?;
789    let samples: Vec<Array1<f64>> = (0..m).map(|i| values.row(i).to_owned()).collect();
790
791    let dispersion = |p: ArrayView1<'_, f64>| -> GeometryResult<f64> {
792        let mut acc = 0.0_f64;
793        for (i, x) in samples.iter().enumerate() {
794            if w[i] == 0.0 {
795                continue;
796            }
797            let lg = manifold.log_point(p, x.view())?;
798            let sq = manifold.sq_metric_norm(p, lg.view())?;
799            acc += w[i] * sq;
800        }
801        Ok(acc)
802    };
803
804    let stationarity = |p: ArrayView1<'_, f64>| -> GeometryResult<(Array1<f64>, f64)> {
805        let mut xi = Array1::<f64>::zeros(ambient);
806        for (i, x) in samples.iter().enumerate() {
807            if w[i] == 0.0 {
808                continue;
809            }
810            let lg = manifold.log_point(p, x.view())?;
811            xi.scaled_add(w[i], &lg);
812        }
813        let residual = manifold.sq_metric_norm(p, xi.view())?.sqrt();
814        Ok((xi, residual))
815    };
816
817    // Safeguarded Riemannian gradient descent from one interior start. The only
818    // success exit is the analytic Karcher certificate `‖Σwᵢlogₚ(xᵢ)‖ₚ≤tol`;
819    // line-search or iteration exhaustion above it is typed non-convergence.
820    let descend = |start: Array1<f64>| -> GeometryResult<(Array1<f64>, f64)> {
821        let mut p = start;
822        let mut f_cur = dispersion(p.view())?;
823        for iteration in 0..max_iter {
824            // Riemannian gradient direction ξ = Σ wᵢ log_p(xᵢ) = −½ grad V.
825            let (xi, grad_norm) = stationarity(p.view())?;
826            if grad_norm <= tol {
827                return Ok((p, grad_norm));
828            }
829
830            // Armijo-backtracked unit Karcher step exp_p(t·ξ). A step that
831            // leaves the manifold's domain (e.g. a Poincaré overshoot past the
832            // ball boundary) or lands where the dispersion is undefined is an
833            // INVALID trial (`Ok(None)`): shrink and retry without consulting
834            // the Armijo test — unlike `spd_frechet_mean`, this generic driver
835            // never aborts the descent on a trial-evaluation error.
836            let pred = grad_norm * grad_norm;
837            let f_tol = armijo_roundoff_cushion(f_cur);
838            let accepted = match backtracking_line_search::<_, Infallible>(
839                BacktrackConfig::default(),
840                |t| {
841                    let step = &xi * t;
842                    let Ok(cand) = manifold.exp_point(p.view(), step.view()) else {
843                        return Ok(None);
844                    };
845                    let Ok(f_cand) = dispersion(cand.view()) else {
846                        return Ok(None);
847                    };
848                    Ok(Some((f_cand, cand)))
849                },
850                |t, f_cand| f_cand <= f_cur - 2.0 * constants::ARMIJO_C1 * t * pred + f_tol,
851            ) {
852                Ok(result) => result,
853                Err(never) => match never {},
854            };
855            let Some(accepted_step) = accepted else {
856                return Err(GeometryError::NonConvergence {
857                    context: "response geometry Fréchet mean",
858                    iterations: iteration + 1,
859                    residual: grad_norm,
860                    tolerance: tol,
861                });
862            };
863            p = accepted_step.payload;
864            f_cur = accepted_step.value;
865        }
866        // The final allowed update can cross the requested threshold.
867        let (_, residual) = stationarity(p.view())?;
868        if residual <= tol {
869            Ok((p, residual))
870        } else {
871            Err(GeometryError::NonConvergence {
872                context: "response geometry Fréchet mean",
873                iterations: max_iter,
874                residual,
875                tolerance: tol,
876            })
877        }
878    };
879
880    // Choose one row-order-invariant positive-mass seed: highest weight, then
881    // lexicographically smallest coordinates. On a Hadamard manifold any seed
882    // reaches the unique global mean. On a positively curved manifold the
883    // support-ball certificate below, rather than an arbitrary number of
884    // restarts, proves that the stationary point is the unique global mean.
885    let mut seed_index: Option<usize> = None;
886    for index in 0..m {
887        if w[index] == 0.0 {
888            continue;
889        }
890        let replace = match seed_index {
891            None => true,
892            Some(current) if w[index] > w[current] => true,
893            Some(current) if w[index] == w[current] => {
894                samples[index]
895                    .iter()
896                    .zip(samples[current].iter())
897                    .find_map(|(&lhs, &rhs)| {
898                        let order = lhs.total_cmp(&rhs);
899                        (order != std::cmp::Ordering::Equal).then_some(order)
900                    })
901                    == Some(std::cmp::Ordering::Less)
902            }
903            Some(_) => false,
904        };
905        if replace {
906            seed_index = Some(index);
907        }
908    }
909    let seed_index = seed_index.ok_or(GeometryError::InvalidPoint(
910        "response geometry Fréchet mean has no positive-weight sample",
911    ))?;
912    let start = manifold.exp_point(
913        samples[seed_index].view(),
914        Array1::<f64>::zeros(ambient).view(),
915    )?;
916    let (mean, stationarity_residual) = descend(start)?;
917
918    if let Some(uniqueness_radius) = manifold.frechet_uniqueness_radius() {
919        let mut support_radius = 0.0_f64;
920        for (index, sample) in samples.iter().enumerate() {
921            if w[index] == 0.0 {
922                continue;
923            }
924            let log = manifold.log_point(mean.view(), sample.view())?;
925            let distance = manifold.sq_metric_norm(mean.view(), log.view())?.sqrt();
926            if !distance.is_finite() {
927                return Err(GeometryError::Singular(
928                    "response geometry Fréchet support radius is non-finite",
929                ));
930            }
931            support_radius = support_radius.max(distance);
932        }
933        if support_radius >= uniqueness_radius {
934            return Err(GeometryError::FrechetMeanNotGloballyCertified {
935                context: "response geometry Fréchet mean",
936                stationarity_residual,
937                tolerance: tol,
938                support_radius,
939                uniqueness_radius,
940            });
941        }
942    }
943
944    Ok(mean)
945}
946
947// ── Curvature as an estimand on the response geometry (#944 stage 4 / #1104) ──
948//
949// `response_geometry="constant_curvature(dim=d)"` does NOT take a fixed κ from
950// the user: κ is ESTIMATED from the manifold-valued responses. At each κ the
951// family `ConstantCurvature{dim, κ}` is laid down and κ is scored by the HONEST
952// change-of-variables likelihood of the observed chart coordinates `yᵢ` w.r.t.
953// ambient Lebesgue measure `dy` — the density that is automatically normalised on
954// the SAME measure in which the data are observed, regardless of how the manifold
955// is parameterised. This is the crux of the #1104 fix.
956//
957// ## Why dispersion alone (and the self-normalising wrapped Gaussian) is degenerate
958//
959// The generative model is the wrapped normal `yᵢ = exp_μ(vᵢ)`, `vᵢ` isotropic at
960// geodesic scale σ. Its density w.r.t. the Riemannian volume `dvol_κ` is
961// `N(sᵢ;0,σ²)/Jᵧ_κ(sᵢ)` with `sᵢ = d_κ(μ,yᵢ)` the geodesic radius and
962// `J_κ(s) = (sn_κ(s)/s)^{d−1}` the exp-map volume Jacobian
963// (`ConstantCurvature::jacobian_radial`). The naive criterion
964// `½nd·ln(Σsᵢ²/nd)` (dispersion only), and even the full `dvol_κ`-density NLL
965// `Σ[sᵢ²/2σ² + (d/2)ln2πσ² + ln J_κ(sᵢ)]`, are SCALE-DEGENERATE: rescaling the
966// manifold radius `R = 1/√|κ|` rescales every `sᵢ` and every volume element, and
967// the σ-profile absorbs the change with no κ information left. That is exactly
968// why a `dvol_κ`-normalised (self-normalising) wrapped Gaussian rails, and why an
969// intrinsic-volume partition function double-counts: the density is already
970// normalised on `dvol_κ`, so re-integrating its volume adds nothing identifying.
971//
972// ## The restoring force is the ambient (chart) volume element at the DATA points
973//
974// Curvature is identified only when the abstract manifold is tied to the CONCRETE
975// observed chart coordinates `yᵢ`. The data are observed as points of `ℝ^d` under
976// Lebesgue `dy`, so the likelihood must be the density w.r.t. `dy`, obtained from
977// the `dvol_κ`-density by the chart volume factor `dvol_κ/dy = λ_{yᵢ}^d`,
978// `λ_y = 2/(1+κ‖y‖²)`:
979//
980// ```text
981//   −ℓ(κ,μ,σ²) = Σᵢ[ sᵢ²/(2σ²) + (d/2)ln(2πσ²) + ln J_κ(sᵢ) − d·ln λ_{yᵢ} ].
982// ```
983//
984// The new term `−d·Σ ln λ_{yᵢ} = d·Σ ln((1+κ‖yᵢ‖²)/2)` is evaluated at every DATA
985// point (not at the mean), so `‖yᵢ‖² > 0` even for mean-centred clouds and it
986// supplies a genuine κ-restoring force: it grows like `+d·κ·Σ‖yᵢ‖²` for small κ
987// and `→ +∞` as κ→+∞ (each `−ln λ_{yᵢ}→+∞`), exactly opposing the dispersion /
988// `ln J_κ` terms which fall as the sphere shrinks. The minimum is therefore
989// INTERIOR at the data-generating curvature. None of `ln J_κ` or `λ` depend on σ,
990// so σ profiles in closed form `σ̂² = D/(nd)`, `D = Σ sᵢ²`.
991//
992// ## Reparameterisation invariance / unit-covariance of κ̂
993//
994// κ carries units of `1/length²`. Under a global rescaling `yᵢ ↦ α·yᵢ` the chart
995// of `M_κ` at scale `α` equals the chart of `M_{κ/α²}` at scale 1 (because
996// `λ` and every geodesic primitive depend on `y` only through `κ‖y‖²`). The whole
997// criterion `V(κ, αy)` therefore equals `V(α²κ, y)`, so its minimiser transforms
998// as `κ̂(αy) = κ̂(y)/α²` — the CORRECT covariance of a curvature with units
999// `1/length²`. The base point μ is held at the κ-independent flat centroid (NOT
1000// re-solved per κ): re-solving the Fréchet mean per κ is precisely what
1001// re-entangles κ with the chart scale and biases the estimate, so it is removed.
1002//
1003// `V_p` is a negative log-evidence (lower is better) so κ̂ = argmin V_p; it is the
1004// full NLL summed over all `n·d` scalar observations, so `2[V_p(0) − V_p(κ̂)]` is
1005// the Wilks LR statistic with a calibrated χ²₁ flatness reference — exactly the
1006// contract `profile_ci_walk` / `flatness_lr_test` in `curvature_estimand.rs`
1007// consume, with no new outer machinery.
1008
1009/// Typed failures from constant-curvature response fitting. In particular,
1010/// optimiser exhaustion carries the exact score/Hessian and the normalized
1011/// box-KKT residual, so a caller never receives a midpoint merely because an
1012/// iteration cap was reached.
1013#[derive(Clone, Debug, PartialEq)]
1014pub enum ResponseGeometryError {
1015    InvalidInput(String),
1016    NumericalGeometry(String),
1017    CurvatureUnidentified {
1018        dispersion: f64,
1019    },
1020    CurvatureNonConvergence {
1021        iterations: usize,
1022        max_iter: usize,
1023        bracket_lo: f64,
1024        bracket_hi: f64,
1025        kappa: f64,
1026        criterion: f64,
1027        score: f64,
1028        curvature: f64,
1029        kkt_residual: f64,
1030        tolerance: f64,
1031    },
1032}
1033
1034impl fmt::Display for ResponseGeometryError {
1035    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1036        match self {
1037            Self::InvalidInput(message) | Self::NumericalGeometry(message) => f.write_str(message),
1038            Self::CurvatureUnidentified { dispersion } => write!(
1039                f,
1040                "response curvature is unidentified: profiled geodesic dispersion is {dispersion:.6e}"
1041            ),
1042            Self::CurvatureNonConvergence {
1043                iterations,
1044                max_iter,
1045                bracket_lo,
1046                bracket_hi,
1047                kappa,
1048                criterion,
1049                score,
1050                curvature,
1051                kkt_residual,
1052                tolerance,
1053            } => write!(
1054                f,
1055                "response curvature did not satisfy its minimizing box-KKT certificate after \
1056                 {iterations}/{max_iter} iterations: bracket=[{bracket_lo:.6e}, \
1057                 {bracket_hi:.6e}], kappa={kappa:.6e}, criterion={criterion:.6e}, \
1058                 score={score:.6e}, normalized KKT residual={kkt_residual:.6e} \
1059                 (required <= {tolerance:.6e}), curvature={curvature:.6e} \
1060                 (required > 0)"
1061            ),
1062        }
1063    }
1064}
1065
1066impl std::error::Error for ResponseGeometryError {}
1067
1068impl From<GeometryError> for ResponseGeometryError {
1069    fn from(error: GeometryError) -> Self {
1070        Self::NumericalGeometry(error.to_string())
1071    }
1072}
1073
1074/// Outcome of fitting curvature as an estimand on a constant-curvature response
1075/// geometry: the optimised κ̂, its tangent base point, the profile-likelihood CI,
1076/// and the interior-point flatness (Wilks) test of κ = 0.
1077#[derive(Clone, Debug)]
1078pub struct ResponseCurvatureFit {
1079    /// The dimension `d` of the constant-curvature response manifold.
1080    pub dim: usize,
1081    /// The REML/evidence-optimal curvature κ̂ (argmin of the profiled criterion).
1082    ///
1083    /// **Units `1/length²`** — κ̂ is therefore *scale-dependent*: rescaling the
1084    /// cloud `y ↦ α·y` rescales `κ̂ ↦ κ̂/α²`. For a scale-free statement of how
1085    /// curved the cloud is, read [`kappa_r2`](Self::kappa_r2) instead. When the
1086    /// cloud is curved BEYOND what its spread can resolve (it fills a large
1087    /// fraction of the sphere `S^d(1/√κ̂)`), the optimiser rails to the
1088    /// chart-resolution cap and [`railed_at_resolution_limit`](Self::railed_at_resolution_limit)
1089    /// is `true`: κ̂ is then a *lower bound on |κ|*, not a point estimate.
1090    pub kappa_hat: f64,
1091    /// The DIMENSIONLESS geometric invariant the cloud actually determines:
1092    /// `κ̂ · r²` with `r` = [`characteristic_radius`](Self::characteristic_radius).
1093    /// This is scale-FREE (`κ̂·r²` is invariant under `y ↦ α·y`, since `κ̂ ↦ κ̂/α²`
1094    /// and `r ↦ α·r`) — the honest answer to "how curved is this cloud relative
1095    /// to its own spread". `|κ̂·r²| ≪ 1` ⇒ nearly flat at this scale; `κ̂·r² ↗ (π/2)²`
1096    /// ⇒ the cloud fills the sphere and curvature is at the chart-resolution limit.
1097    pub kappa_r2: f64,
1098    /// Characteristic geodesic radius `r` of the cloud at κ = 0 (the doubled-gauge
1099    /// chart distance `r = 2·max_i‖y_i − μ‖`): the length scale against which κ̂ is
1100    /// dimensionless. Reported so the caller can convert between scale-dependent κ̂
1101    /// and the scale-free `κ̂·r²` without re-deriving the chart gauge.
1102    pub characteristic_radius: f64,
1103    /// The intrinsic Fréchet-mean base point at κ̂ (the tangent expansion point
1104    /// the scalar GAMs are fitted around).
1105    pub base: Array1<f64>,
1106    /// Profiled criterion value `V_p(κ̂)` (concentrated negative log-evidence).
1107    pub v_p_hat: f64,
1108    /// `true` when the κ̂ search converged ONTO the chart-resolution cap rather
1109    /// than an interior optimum: the data want curvature at or beyond the
1110    /// conjugate radius of their geodesic spread (the cloud fills the sphere).
1111    /// In that case κ̂ / the CI upper end are NOT a resolved point estimate but a
1112    /// HONEST "curvature exceeds chart-resolvable range at this scale" flag — the
1113    /// caller must report it as such and never as a silent `κ̂ = ci_hi`.
1114    pub railed_at_resolution_limit: bool,
1115    /// Twin of [`railed_at_resolution_limit`](Self::railed_at_resolution_limit)
1116    /// for the HYPERBOLIC side (#2351): `true` when the κ̂ search converged ONTO
1117    /// the lower chart-domain bound — the criterion is still improving as κ
1118    /// decreases at the limit where the cloud fills the hyperbolic ball of its
1119    /// own spread (the mean-centred chart-validity edge `1 + κ‖z_max‖² → 0⁺`,
1120    /// where the conformal restoring force diverges linearly and beats the
1121    /// log-log dispersion term, so the criterion genuinely runs away). κ̂ is
1122    /// then an UPPER bound on κ, not a resolved point estimate; the caller must
1123    /// report "curvature exceeds the chart-resolvable hyperbolic range at this
1124    /// scale" and never quote a confident hyperbolic verdict off the rail.
1125    pub railed_at_hyperbolic_resolution_limit: bool,
1126    /// `true` only when the SIGN of κ̂ is statistically resolved — i.e. the
1127    /// profile-likelihood CI excludes 0 (`profile_ci.verdict ≠ Flat`).
1128    ///
1129    /// ## Why a point estimate alone is not enough (the #944/#1059 flat-floor)
1130    ///
1131    /// Curvature is resolvable only through the dimensionless product `κ·r²`
1132    /// (see [`kappa_r2`](Self::kappa_r2)); the per-point Fisher information for κ
1133    /// scales like `σ⁴`. When the cloud is nearly flat at its own scale
1134    /// (`|κ·r²| ≪ 1`), the profiled criterion is so shallow that its single-cloud
1135    /// argmin κ̂ can land on the WRONG SIDE OF ZERO purely by Monte-Carlo
1136    /// fluctuation — empirically a coin-flip below `|κ·r²| ≈ 0.03`, reliable above
1137    /// `≈ 0.09` (the #944 power curve). The estimand itself is UNBIASED (the
1138    /// criterion averaged over clouds minimises exactly at κ⋆), so this is a
1139    /// resolution limit, not a bias.
1140    ///
1141    /// The CI, in contrast, is honest in this regime: at an under-resolved
1142    /// operating point it reports `Flat` (straddles 0) rather than a confident
1143    /// wrong sign — it essentially never claims the wrong-signed geometry. So the
1144    /// SIGN-bearing summary the caller may quote is the CI verdict, not the bare
1145    /// κ̂. This flag exposes that contract on the point-estimate surface: when it
1146    /// is `false`, κ̂'s sign is noise — the caller must report "curvature not
1147    /// resolved at this scale (|κ·r²| too small)" and quote the CI / `kappa_r2`,
1148    /// never a sign-confident κ̂. It is the flat-floor twin of
1149    /// [`railed_at_resolution_limit`](Self::railed_at_resolution_limit) (the
1150    /// spherical-cap rail); together they bracket the two ends of the resolvable
1151    /// `κ·r²` band where κ̂ is a genuine interior point estimate.
1152    pub sign_resolved: bool,
1153    /// Profile-likelihood CI for κ and the geometry verdict from its sign.
1154    pub profile_ci: crate::curvature_estimand::KappaProfileCi,
1155    /// Interior-point χ²₁ likelihood-ratio test of flatness (κ = 0).
1156    pub flatness: crate::curvature_estimand::FlatnessTest,
1157}
1158
1159/// Chart-validity bounds on κ for a constant-curvature response geometry built
1160/// from the supplied responses, plus the characteristic geodesic radius
1161/// `ρ_max = 2·max_i‖y_i − μ‖` against which κ is made dimensionless.
1162///
1163/// Returns `(kappa_min, kappa_max, rho_max)`.
1164///
1165/// * **Lower (hyperbolic) bound.** The κ-stereographic chart requires
1166///   `1 + κ‖x‖² > 0` at every point measured from the chart origin, i.e.
1167///   `κ > −1/R²` with `R² = max_i ‖y_i‖²`. The open boundary is
1168///   approached only to the relative resolution of f64 arithmetic.
1169/// * **Upper (spherical) bound.** Unlike the hyperbolic side this is NOT
1170///   unbounded: on a sphere of curvature κ the geodesic radius cannot exceed the
1171///   conjugate radius `π/√κ`, beyond which the exp-map volume Jacobian
1172///   `J_κ = (sn_κ/·)^{d−1}` changes sign (clamped to 0 here) and `ln J_κ` would
1173///   collapse `V_p` toward `−∞`, railing the optimiser onto a spurious shell.
1174///   The κ = 0 geodesic radius of the farthest point from the centroid is
1175///   `ρ_max = 2·max_i‖y_i − μ‖` (doubled-gauge chart). We cap κ so that radius
1176///   stays strictly inside the first conjugate shell to f64-relative resolution:
1177///   `√κ·ρ_max < π`. This keeps every geodesic radius before the
1178///   antipodal singularity along the whole search/CI walk without an arbitrary
1179///   fractional margin.
1180///
1181/// `κ_max` is the chart-RESOLUTION limit of the cloud: at it the geodesic spread
1182/// fills the conjugate shell to machine resolution, i.e. the cloud nearly fills
1183/// the sphere `S^d(1/√κ_max)`. The DIMENSIONLESS product `κ_max·ρ_max²
1184/// → π²` is fixed and data-scale-free — it is the natural "the cloud is
1185/// maximally curved relative to its spread" sentinel the rail check compares κ̂ to.
1186fn response_kappa_bounds(values: ArrayView2<'_, f64>) -> (f64, f64, f64) {
1187    let (n_rows, dim) = values.dim();
1188    // BOTH rails derive from the centroid-relative spread ‖y_i − μ‖² — the only
1189    // translation-invariant "how spread is this cloud" quantity. The chart
1190    // origin is IDENTIFIED with the cloud's flat centroid (the criterion
1191    // evaluates on the mean-centred coordinates z_i = y_i − μ, #2351), so the
1192    // hyperbolic chart-domain constraint 1 + κ‖z‖² > 0 is governed by the same
1193    // spread as the spherical conjugate-radius cap. The previous ambient-origin
1194    // radius made κ_min collapse to ≈ −1 for any unit-normalised cloud
1195    // regardless of its shape — a pure-translation-sensitive verdict.
1196    let mut centroid = Array1::<f64>::zeros(dim.max(1));
1197    if n_rows > 0 && dim > 0 {
1198        for row in values.outer_iter() {
1199            centroid += &row;
1200        }
1201        centroid.mapv_inplace(|v| v / n_rows as f64);
1202    }
1203    let mut s2_max = 0.0_f64;
1204    if dim > 0 {
1205        for row in values.outer_iter() {
1206            let diff = &row - &centroid;
1207            let r2 = diff.dot(&diff);
1208            if r2 > s2_max {
1209                s2_max = r2;
1210            }
1211        }
1212    }
1213    assert!(
1214        s2_max > 0.0,
1215        "response κ bounds require a non-degenerate cloud: max ‖y−μ‖²={s2_max}"
1216    );
1217    // Stay one square-root-epsilon relative step inside both open singular
1218    // boundaries. This is derived from f64 resolution, not a tuning knob.
1219    let open_boundary = 1.0 - f64::EPSILON.sqrt();
1220    let kappa_min = -open_boundary / s2_max;
1221    // Conjugate-radius cap: ρ_max = 2·max‖y_i − μ‖ is the κ=0 geodesic radius.
1222    let rho_max = 2.0 * s2_max.sqrt();
1223    let edge = open_boundary * std::f64::consts::PI / rho_max;
1224    let kappa_max = edge * edge;
1225    (kappa_min, kappa_max, rho_max)
1226}
1227
1228/// Profiled curvature criterion `V_p(κ)` for the constant-curvature response
1229/// geometry: the σ-profiled HONEST change-of-variables negative log-likelihood of
1230/// the observed chart coordinates `y_i` at curvature `κ`, expressed w.r.t. ambient
1231/// Lebesgue measure `dy`. Lower is better (κ̂ = argmin). Returns `(V_p, base)`;
1232/// the base point is the κ-INDEPENDENT flat centroid (the tangent expansion point
1233/// that the scalar GAMs are fitted around), held fixed across κ so the estimate is
1234/// not re-entangled with the chart scale.
1235///
1236/// The model is the wrapped normal `y_i = exp_{μ,κ}(v_i)` with isotropic geodesic
1237/// scale σ; `s_i = d_κ(μ, y_i)` is the geodesic radius and `J_κ(s)` the exp-map
1238/// volume Jacobian. The density on the Riemannian volume `dvol_κ` is
1239/// `N(s_i;0,σ²)/J_κ(s_i)`; converting to ambient `dy` multiplies by the chart
1240/// volume factor `λ_{y_i}^d`, `λ_y = 2/(1+κ‖y‖²)`. The negative log-likelihood is
1241///
1242/// ```text
1243///   −ℓ(κ,σ²) = Σ_i[ s_i²/(2σ²) + (d/2)ln(2πσ²) + ln J_κ(s_i) − d·ln λ_{y_i} ].
1244/// ```
1245///
1246/// `ln J_κ` and `λ` do not depend on σ, so σ profiles in closed form
1247/// `σ̂² = D/(nd)`, `D = Σ s_i²`. The `−d·Σ ln λ_{y_i}` term — evaluated at the DATA
1248/// points, not the mean — is the κ-restoring force that breaks the scale
1249/// degeneracy of the dispersion / `dvol_κ`-density alone (see the module notes).
1250/// Additive constants independent of κ are kept implicit; they cancel in every
1251/// LR / profile-drop the CI machinery forms. μ is the closed-form flat centroid,
1252/// so the criterion is a pure function of κ with no inner tolerance/iteration
1253/// budget (the outer κ̂ search owns those).
1254pub fn response_curvature_criterion(
1255    values: ArrayView2<'_, f64>,
1256    dim: usize,
1257    kappa: f64,
1258) -> Result<(f64, Array1<f64>), String> {
1259    response_curvature_criterion_jet(values, dim, kappa)
1260        .map(|jet| (jet.value, jet.base))
1261        .map_err(|error| error.to_string())
1262}
1263
1264#[derive(Clone, Debug)]
1265struct CurvatureCriterionJet {
1266    kappa: f64,
1267    value: f64,
1268    score: f64,
1269    curvature: f64,
1270    base: Array1<f64>,
1271}
1272
1273/// Hand-derived value, score, and Hessian of the profiled criterion. Every
1274/// derivative is assembled from the closed-form distance κ-jet and analytic
1275/// chain rules; no production finite difference or autodiff is involved.
1276fn response_curvature_criterion_jet(
1277    values: ArrayView2<'_, f64>,
1278    dim: usize,
1279    kappa: f64,
1280) -> Result<CurvatureCriterionJet, ResponseGeometryError> {
1281    if !kappa.is_finite() {
1282        return Err(ResponseGeometryError::InvalidInput(
1283            "response curvature criterion: kappa must be finite".into(),
1284        ));
1285    }
1286    let (n_rows, cols) = values.dim();
1287    if n_rows == 0 || cols != dim || dim == 0 {
1288        return Err(ResponseGeometryError::InvalidInput(format!(
1289            "response curvature criterion: values must be N×{dim} with N >= 1"
1290        )));
1291    }
1292    // κ-independent base point: the flat (ambient) centroid. Holding μ fixed across
1293    // κ is the de-entangling move — re-solving the Fréchet mean per κ couples the
1294    // base to the chart scale and biases κ̂ (#1104 root cause).
1295    let mut base = Array1::<f64>::zeros(dim);
1296    for row in values.outer_iter() {
1297        base += &row;
1298    }
1299    base.mapv_inplace(|v| v / n_rows as f64);
1300
1301    let chart = ConstantCurvature::new(dim, kappa);
1302    let d = dim as f64;
1303    let mut dispersion = 0.0_f64;
1304    let mut dispersion_d1 = 0.0_f64;
1305    let mut dispersion_d2 = 0.0_f64;
1306    let mut ln_jac = 0.0_f64;
1307    let mut ln_jac_d1 = 0.0_f64;
1308    let mut ln_jac_d2 = 0.0_f64;
1309    let mut chart_volume = 0.0_f64;
1310    let mut chart_volume_d1 = 0.0_f64;
1311    let mut chart_volume_d2 = 0.0_f64;
1312
1313    // #2351: the chart origin is IDENTIFIED with the flat centroid — every
1314    // per-row quantity evaluates on the mean-centred coordinate z_i = y_i − μ.
1315    // This is the translation-invariant model: y ↦ y + t leaves every z_i (and
1316    // hence V_p, κ̂, the verdict, and both rail flags) exactly unchanged, while
1317    // z ↦ dy is unit-Jacobian so the observed-measure likelihood is unaffected.
1318    // (Möbius recentring w = (−μ)⊕_κ y does NOT achieve this: gyro-addition
1319    // does not commute with Euclidean translation, and w is κ-dependent.)
1320    // The centred distance collapses the Möbius denominator to 1, so the
1321    // hyperbolic side has no off-origin antipodal singularity.
1322    let origin = Array1::<f64>::zeros(dim);
1323    for row in values.outer_iter() {
1324        let centred = &row - &base;
1325        let (r, r_d1, r_d2) = distance_kappa_jet(&chart, origin.view(), centred.view())?;
1326        dispersion += r * r;
1327        dispersion_d1 += 2.0 * r * r_d1;
1328        dispersion_d2 += 2.0 * (r_d1 * r_d1 + r * r_d2);
1329
1330        if dim > 1 {
1331            // J_κ(r)=S(u)^(d−1), u=κr². Chain-rule jets of u.
1332            let u = kappa * r * r;
1333            let u_d1 = r * r + 2.0 * kappa * r * r_d1;
1334            let u_d2 = 4.0 * r * r_d1 + 2.0 * kappa * (r_d1 * r_d1 + r * r_d2);
1335            let s = cs_stacks3(u).1;
1336            if !(s[0].is_finite() && s[0] > 0.0) {
1337                return Err(ResponseGeometryError::NumericalGeometry(
1338                    "response curvature criterion reached the conjugate shell".into(),
1339                ));
1340            }
1341            let log_s_d1 = s[1] / s[0];
1342            let log_s_d2 = s[2] / s[0] - log_s_d1 * log_s_d1;
1343            let exponent = (dim - 1) as f64;
1344            ln_jac += exponent * s[0].ln();
1345            ln_jac_d1 += exponent * log_s_d1 * u_d1;
1346            ln_jac_d2 += exponent * (log_s_d2 * u_d1 * u_d1 + log_s_d1 * u_d2);
1347        }
1348
1349        // −d ln λ_z = d[ln(1+κ‖z‖²)−ln 2], evaluated at the CENTRED coordinate
1350        // (#2351): the κ-restoring force reads the cloud's spread, not its
1351        // arbitrary ambient offset.
1352        let q = centred.dot(&centred);
1353        let gauge = 1.0 + kappa * q;
1354        if !(gauge.is_finite() && gauge > 0.0) {
1355            return Err(ResponseGeometryError::NumericalGeometry(
1356                "response curvature criterion reached the chart boundary".into(),
1357            ));
1358        }
1359        chart_volume += d * (gauge.ln() - std::f64::consts::LN_2);
1360        chart_volume_d1 += d * q / gauge;
1361        chart_volume_d2 -= d * q * q / (gauge * gauge);
1362    }
1363    let nobs = (n_rows * dim) as f64;
1364    if !(dispersion.is_finite() && dispersion > 0.0) {
1365        return Err(ResponseGeometryError::CurvatureUnidentified { dispersion });
1366    }
1367
1368    // σ profiles in closed form: σ̂² = D/(nd). Substituting and dropping the
1369    // κ-independent constant (nd/2)(1 + ln 2π):
1370    //   V_p(κ) = (nd/2)·ln(D/(nd)) + Σ ln J_κ(s_i) − d·Σ ln λ_{y_i}.
1371    let value = 0.5 * nobs * (dispersion / nobs).ln() + ln_jac + chart_volume;
1372    let score = 0.5 * nobs * dispersion_d1 / dispersion + ln_jac_d1 + chart_volume_d1;
1373    let curvature = 0.5
1374        * nobs
1375        * (dispersion_d2 / dispersion
1376            - (dispersion_d1 / dispersion) * (dispersion_d1 / dispersion))
1377        + ln_jac_d2
1378        + chart_volume_d2;
1379    if !value.is_finite() || !score.is_finite() || !curvature.is_finite() {
1380        return Err(ResponseGeometryError::NumericalGeometry(
1381            "response curvature criterion jet is non-finite".into(),
1382        ));
1383    }
1384    Ok(CurvatureCriterionJet {
1385        kappa,
1386        value,
1387        score,
1388        curvature,
1389        base,
1390    })
1391}
1392
1393/// Fit curvature as an estimand on a constant-curvature response geometry.
1394///
1395/// κ̂ is the minimiser of the profiled criterion [`response_curvature_criterion`]
1396/// (the σ-profiled honest change-of-variables negative log-evidence of the wrapped
1397/// normal w.r.t. ambient measure), found by a safeguarded root solve of its
1398/// exact analytic score inside the chart-validity bracket. The base point μ is
1399/// the κ-independent flat centroid, so
1400/// every `V_p` evaluation scores the SAME geometry without re-entangling κ with the
1401/// chart scale (the #1104 fix). The exact outer
1402/// curvature `V_p''(κ̂)` is evaluated by the same hand-derived criterion jet
1403/// and handed to [`profile_ci_walk`](crate::profile_ci_walk)
1404/// to size the initial Wald step; the CI itself is the exact χ²₁ profile crossing.
1405/// Flatness is the interior-point χ²₁ LR test
1406/// [`flatness_lr_test`](crate::flatness_lr_test). κ = 0 is an interior
1407/// point of the analytic `S^d ← ℝ^d → H^d` family, so no boundary correction is
1408/// applied. Returns the κ̂, its tangent base point, the profile CI, and the Wilks
1409/// flatness test for the fit summary.
1410///
1411/// ## Scale-awareness and honest railing (#1104)
1412///
1413/// κ has units `1/length²`, so a cloud of characteristic geodesic radius `r`
1414/// resolves only the DIMENSIONLESS product `κ·r²` (every chart primitive depends
1415/// on `y` through `κ‖y‖²`, hence `V(κ, αy) = V(α²κ, y)` and `κ̂ ↦ κ̂/α²` under
1416/// `y ↦ αy`). The fit therefore also returns:
1417/// * `kappa_r2 = κ̂·r²` — the scale-FREE invariant the cloud actually determines
1418///   (how curved relative to its own spread), and `characteristic_radius = r`;
1419/// * `railed_at_resolution_limit` — `true` when the data want curvature at or
1420///   beyond the conjugate radius of their spread (the cloud fills the sphere),
1421///   so the search converges onto the spherical cap. There κ̂ is a LOWER BOUND on
1422///   `|κ|`, not a resolved point estimate, and the caller must report "curvature
1423///   exceeds chart-resolvable range at this scale" rather than silently quoting
1424///   `κ̂ = ci_hi`. This is the #1104 fix: a tightly-concentrated near-spherical
1425///   cloud (e.g. unit-normalised OLMo activations) no longer SILENTLY rails to a
1426///   huge scale-dependent `ci_hi` while claiming a point estimate + CI.
1427pub fn fit_response_curvature(
1428    values: ArrayView2<'_, f64>,
1429    dim: usize,
1430    level: f64,
1431    tol: f64,
1432    max_iter: usize,
1433) -> Result<ResponseCurvatureFit, ResponseGeometryError> {
1434    if dim == 0 {
1435        return Err(ResponseGeometryError::InvalidInput(
1436            "constant-curvature response geometry requires dim >= 1".into(),
1437        ));
1438    }
1439    let (n_rows, cols) = values.dim();
1440    if n_rows == 0 || cols != dim {
1441        return Err(ResponseGeometryError::InvalidInput(format!(
1442            "constant-curvature response geometry: values must be N×{dim} with N >= 1"
1443        )));
1444    }
1445    if !(level > 0.0 && level < 1.0) {
1446        return Err(ResponseGeometryError::InvalidInput(
1447            "response curvature CI level must lie in (0, 1)".into(),
1448        ));
1449    }
1450    if !(tol.is_finite() && tol > 0.0) {
1451        return Err(ResponseGeometryError::InvalidInput(
1452            "response curvature tolerance must be finite and positive".into(),
1453        ));
1454    }
1455
1456    // Establish identifiability at the flat member before constructing bounds;
1457    // a zero-dispersion point cloud carries no curvature scale.
1458    let flat_jet = response_curvature_criterion_jet(values, dim, 0.0)?;
1459    let (kappa_min, kappa_max, rho_max) = response_kappa_bounds(values);
1460    let span = kappa_max - kappa_min;
1461    let nobs = (n_rows * dim) as f64;
1462    if !(span.is_finite() && span > 0.0) {
1463        return Err(ResponseGeometryError::NumericalGeometry(
1464            "response curvature chart bracket is not finite and ordered".into(),
1465        ));
1466    }
1467
1468    // `V_p` as a closure over the criterion; threaded through both the κ̂ search
1469    // and the CI walk. Every evaluation uses the same κ-independent flat-centroid
1470    // base, so the criterion is a clean 1-D function of κ.
1471    let mut v_p = |kappa: f64| -> Result<f64, String> {
1472        response_curvature_criterion(values, dim, kappa).map(|(v, _)| v)
1473    };
1474
1475    // ── κ̂: analytic score root / constrained box-KKT solve. ─────────────
1476    // `(span/nobs)·|V'|` is dimensionless, response-scale invariant, and row-
1477    // replication invariant. At a bound only the outward score component is a
1478    // KKT violation.
1479    let normalized_kkt = |kappa: f64, score: f64| {
1480        let violation = if kappa == kappa_min {
1481            (-score).max(0.0)
1482        } else if kappa == kappa_max {
1483            score.max(0.0)
1484        } else {
1485            score.abs()
1486        };
1487        span * violation / nobs
1488    };
1489
1490    let lower = response_curvature_criterion_jet(values, dim, kappa_min)?;
1491    let upper = response_curvature_criterion_jet(values, dim, kappa_max)?;
1492    let mut a = kappa_min;
1493    let mut b = kappa_max;
1494    let mut iterations = 0_usize;
1495    let (jet, railed_at_resolution_limit, railed_at_hyperbolic_resolution_limit) =
1496        if lower.score >= 0.0 {
1497            // V'(κ_min) ≥ 0: the constrained minimum sits ON the hyperbolic
1498            // chart-domain bound — the criterion is still improving as κ decreases
1499            // past the limit where the cloud fills the hyperbolic ball of its own
1500            // spread. Exactly symmetric to the spherical rail below (#2351): κ̂ is
1501            // an UPPER bound on κ, not a resolved point estimate, and must be
1502            // reported as railed rather than as a confident hyperbolic verdict.
1503            (lower, false, true)
1504        } else if upper.score <= 0.0 {
1505            // V'(κ_max)≤0 means the criterion is still improving at the
1506            // spherical chart-resolution limit.
1507            (upper, true, false)
1508        } else {
1509            let mut current = flat_jet;
1510            while iterations < max_iter {
1511                iterations += 1;
1512                if normalized_kkt(current.kappa, current.score) <= tol && current.curvature > 0.0 {
1513                    break;
1514                }
1515                if current.score < 0.0 {
1516                    a = current.kappa;
1517                } else {
1518                    b = current.kappa;
1519                }
1520
1521                // Newton's score step supplies local quadratic convergence; the
1522                // analytic sign bracket safeguards it globally. An inadmissible
1523                // Newton point is replaced by the strictly contracting midpoint.
1524                let newton = current.kappa - current.score / current.curvature;
1525                let next =
1526                    if current.curvature > 0.0 && newton.is_finite() && newton > a && newton < b {
1527                        newton
1528                    } else {
1529                        0.5 * (a + b)
1530                    };
1531                current = response_curvature_criterion_jet(values, dim, next)?;
1532            }
1533            let residual = normalized_kkt(current.kappa, current.score);
1534            if residual > tol || current.curvature <= 0.0 {
1535                return Err(ResponseGeometryError::CurvatureNonConvergence {
1536                    iterations,
1537                    max_iter,
1538                    bracket_lo: a,
1539                    bracket_hi: b,
1540                    kappa: current.kappa,
1541                    criterion: current.value,
1542                    score: current.score,
1543                    curvature: current.curvature,
1544                    kkt_residual: residual,
1545                    tolerance: tol,
1546                });
1547            }
1548            (current, false, false)
1549        };
1550    let kappa_hat = jet.kappa;
1551    // #2351: the hyperbolic rail flag must also fire on the BOUNDARY-LAYER
1552    // interior optimum. Near the chart-domain edge the conformal restoring
1553    // force diverges and can pin a nominally-interior stationary point a
1554    // fraction of a percent inside κ_min (measured on isotropic unit-vector
1555    // clouds: κ̂/κ_min ≈ 0.997 with p → 0). Dimensionlessly, κ̂ ≤ 0.99·κ_min
1556    // means the fitted curvature says the cloud fills ≥ 99% of the hyperbolic
1557    // ball of its own spread — the estimate is chart-limited, not resolved,
1558    // regardless of whether the KKT condition binds exactly AT the bound.
1559    let railed_at_hyperbolic_resolution_limit =
1560        railed_at_hyperbolic_resolution_limit || kappa_hat <= 0.99 * kappa_min;
1561    let v_p_hat = jet.value;
1562    let base = jet.base.clone();
1563
1564    // The upper rail flag comes only from the exact active-bound KKT condition
1565    // `V'(κ_max) ≤ 0`; proximity to a bound is not treated as convergence.
1566    // Dimensionless scale-free invariant κ̂·r²: the geometric content the cloud
1567    // actually determines (invariant under y ↦ αy). r = ρ_max is the κ=0 doubled-
1568    // gauge characteristic radius; for a degenerate (point) cloud r = 0 and the
1569    // product is 0 (κ unidentified). This is what the caller should report as the
1570    // honest "how curved relative to its spread" number alongside the dimensional κ̂.
1571    let kappa_r2 = kappa_hat * rho_max * rho_max;
1572
1573    let kappa_tol = tol * span;
1574    if !(kappa_tol.is_finite() && kappa_tol > 0.0) {
1575        return Err(ResponseGeometryError::InvalidInput(
1576            "response curvature tolerance underflows in the chart scale".into(),
1577        ));
1578    }
1579    let profile_ci = crate::curvature_estimand::profile_ci_walk(
1580        &mut v_p,
1581        kappa_hat,
1582        jet.curvature,
1583        kappa_min,
1584        kappa_max,
1585        level,
1586        kappa_tol,
1587    )
1588    .map_err(ResponseGeometryError::NumericalGeometry)?;
1589    let flatness = crate::curvature_estimand::flatness_lr_test(&mut v_p, kappa_hat)
1590        .map_err(ResponseGeometryError::NumericalGeometry)?;
1591
1592    // The sign of κ̂ is statistically resolved iff the profile CI excludes 0 — the
1593    // CI is the honest sign-bearing summary (it reports Flat under-resolution rather
1594    // than a confident wrong sign), so we mirror its verdict onto the point-estimate
1595    // surface. Below the resolvable `κ·r²` floor (`|κ·r²| ≪ 1`) the bare κ̂ argmin can
1596    // flip sign on Monte-Carlo noise, so `false` here means "do not quote κ̂'s sign".
1597    let sign_resolved = !matches!(
1598        profile_ci.verdict,
1599        crate::curvature_estimand::CurvatureVerdict::Flat
1600    );
1601
1602    Ok(ResponseCurvatureFit {
1603        dim,
1604        kappa_hat,
1605        kappa_r2,
1606        characteristic_radius: rho_max,
1607        railed_at_resolution_limit,
1608        railed_at_hyperbolic_resolution_limit,
1609        sign_resolved,
1610        base,
1611        v_p_hat,
1612        profile_ci,
1613        flatness,
1614    })
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619    use super::*;
1620    use ndarray::{Array2, array};
1621
1622    fn round_trip(manifold: ResponseManifold, values: Array2<f64>) {
1623        let base =
1624            response_frechet_mean(manifold, values.view(), None, 1e-12, 500).expect("frechet mean");
1625        // The six `*_round_trip_and_mean` tests used to check nothing about the
1626        // MEAN: exp∘log is an involution at ANY base point, so a
1627        // `response_frechet_mean` that returned `values.row(0)` passed all six.
1628        // `frechet_residual` re-derives the analytic Karcher stationarity
1629        // residual independently, which is the property the names claim.
1630        //
1631        // Bound source: the solver's OWN tolerance. `response_frechet_mean`'s
1632        // only success exit is the certificate ‖Σwᵢlogₚ(xᵢ)‖ₚ ≤ tol, and it is
1633        // called here with tol = 1e-12. 1e-10 is 100× that, covering only the
1634        // summation-order difference between this re-derivation and the
1635        // solver's own sum. Widening it past ~1e-12 stops testing the
1636        // certificate at all.
1637        let residual = frechet_residual(manifold, values.view(), base.view());
1638        assert!(
1639            residual <= 1e-10,
1640            "{manifold:?} Fréchet mean is not stationary: residual {residual:.3e} > 1e-10 \
1641             (the solver's success exit certified it at <= 1e-12)"
1642        );
1643        let tangent = response_log_map(manifold, values.view(), base.view()).expect("log map");
1644        let back = response_exp_map(manifold, tangent.view(), base.view()).expect("exp map");
1645        for row in 0..values.nrows() {
1646            for col in 0..values.ncols() {
1647                // Bound source: the ulp scale of the maps under test, NOT the
1648                // quality of `base` -- log then exp at the SAME base is an
1649                // involution however bad that base point is. Spd, Grassmann,
1650                // Stiefel(k=1) and Poincaré are closed form on O(1) data, so
1651                // their floor is a few ε ≈ 1e-15. The only iterative map
1652                // reached from here is the Stiefel k ≥ 2 canonical logarithm,
1653                // whose own inner gate is `TOL = 1.0e-13`
1654                // (manifolds/stiefel.rs); 1e-11 is 100× that gate. The old 1e-6
1655                // let a genuine 1e-8 error in exactly that k ≥ 2 logarithm --
1656                // which two of these six fixtures exist to guard -- pass
1657                // silently.
1658                assert!(
1659                    (back[[row, col]] - values[[row, col]]).abs() < 1e-11,
1660                    "{manifold:?} exp∘log mismatch at ({row},{col}): {} vs {}",
1661                    back[[row, col]],
1662                    values[[row, col]]
1663                );
1664            }
1665        }
1666    }
1667
1668    #[test]
1669    fn spd_round_trip_and_mean() {
1670        // Three 2×2 SPD matrices, row-major flat.
1671        let values = array![
1672            [2.0, 0.0, 0.0, 1.0],
1673            [1.0, 0.3, 0.3, 2.0],
1674            [3.0, -0.5, -0.5, 1.5],
1675        ];
1676        round_trip(ResponseManifold::Spd { n: 2 }, values);
1677    }
1678
1679    #[test]
1680    fn grassmann_round_trip_and_mean() {
1681        // Gr(1, 3): unit columns (lines through the origin), n·k = 3 flat.
1682        let (c1, s1) = (0.2_f64.cos(), 0.2_f64.sin());
1683        let (c2, s2) = (0.35_f64.cos(), 0.35_f64.sin());
1684        let values = array![[1.0, 0.0, 0.0], [c1, s1, 0.0], [c2, s2, 0.0],];
1685        round_trip(ResponseManifold::Grassmann { k: 1, n: 3 }, values);
1686    }
1687
1688    #[test]
1689    fn stiefel_round_trip_and_mean() {
1690        // St(1, 3): unit 1-frames in ℝ³ (== sphere S²).
1691        let (c1, s1) = (0.2_f64.cos(), 0.2_f64.sin());
1692        let (c2, s2) = (0.3_f64.cos(), 0.3_f64.sin());
1693        let values = array![[1.0, 0.0, 0.0], [c1, s1, 0.0], [c2, 0.0, s2],];
1694        round_trip(ResponseManifold::Stiefel { k: 1, n: 3 }, values);
1695    }
1696
1697    #[test]
1698    fn stiefel_k2_round_trip_and_mean_n_lt_2k() {
1699        // St(3, 2): three orthonormal 2-frames in ℝ³ clustered near [e0, e1],
1700        // exercising the genuine canonical-metric logarithm (k ≥ 2) through the
1701        // full Karcher-mean → log → exp round trip. This is the n < 2k regime
1702        // (n = 3 < 2k = 4) where the economical 2k-block form is rank-deficient.
1703        // Before the k ≥ 2 Stiefel logarithm existed this aborted in
1704        // Fréchet-mean init with a misleading cut-locus error (#1637).
1705        let (c2, s2) = (0.2_f64.cos(), 0.2_f64.sin());
1706        let (c1, s1) = (0.15_f64.cos(), 0.15_f64.sin());
1707        let values = array![
1708            [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1709            [c2, 0.0, 0.0, 1.0, s2, 0.0],
1710            [1.0, 0.0, 0.0, c1, 0.0, s1],
1711        ];
1712        round_trip(ResponseManifold::Stiefel { k: 2, n: 3 }, values);
1713    }
1714
1715    #[test]
1716    fn stiefel_k2_round_trip_and_mean_n_ge_2k() {
1717        // St(4, 2): the n ≥ 2k regime (n = 4 = 2k), clustered 2-frames in ℝ⁴.
1718        let (c0, s0) = (0.1_f64.cos(), 0.1_f64.sin());
1719        let (c1, s1) = (0.12_f64.cos(), 0.12_f64.sin());
1720        let values = array![
1721            [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
1722            [c0, 0.0, 0.0, 1.0, s0, 0.0, 0.0, 0.0],
1723            [1.0, 0.0, 0.0, c1, 0.0, 0.0, 0.0, s1],
1724        ];
1725        round_trip(ResponseManifold::Stiefel { k: 2, n: 4 }, values);
1726    }
1727
1728    #[test]
1729    fn poincare_round_trip_and_mean() {
1730        let values = array![[0.1, 0.2], [-0.3, 0.1], [0.2, -0.25],];
1731        round_trip(
1732            ResponseManifold::Poincare {
1733                dim: 2,
1734                curvature: -1.0,
1735            },
1736            values,
1737        );
1738    }
1739
1740    /// Deterministic Fibonacci-lattice cover of S² (== `St(3,1)` == `Gr(1,3)`
1741    /// projectively), spread over the WHOLE sphere. This is the widely spread
1742    /// cloud that makes the Fréchet objective nearly flat, so a single-seed
1743    /// Karcher descent converges only linearly and exhausts a `max_iter=256`
1744    /// budget — the #2140 trigger.
1745    fn fibonacci_sphere(n: usize) -> Array2<f64> {
1746        let mut v = Array2::<f64>::zeros((n, 3));
1747        let golden = std::f64::consts::PI * (1.0 + 5.0_f64.sqrt());
1748        for idx in 0..n {
1749            let i = idx as f64 + 0.5;
1750            let phi = (1.0 - 2.0 * i / n as f64).acos();
1751            let theta = golden * i;
1752            v[[idx, 0]] = theta.cos() * phi.sin();
1753            v[[idx, 1]] = theta.sin() * phi.sin();
1754            v[[idx, 2]] = phi.cos();
1755        }
1756        v
1757    }
1758
1759    /// Analytic Karcher stationarity residual for a uniform-weight cloud.
1760    fn frechet_residual(
1761        manifold: ResponseManifold,
1762        values: ArrayView2<'_, f64>,
1763        p: ArrayView1<'_, f64>,
1764    ) -> f64 {
1765        let mut xi = Array1::<f64>::zeros(values.ncols());
1766        for row in 0..values.nrows() {
1767            let lg = manifold.log_point(p, values.row(row)).expect("log map");
1768            xi.scaled_add(1.0 / values.nrows() as f64, &lg);
1769        }
1770        manifold
1771            .sq_metric_norm(p, xi.view())
1772            .expect("metric norm")
1773            .sqrt()
1774    }
1775
1776    #[test]
1777    fn successful_stiefel_k1_frechet_mean_is_analytically_stationary() {
1778        let inv = 1.0 / 1.01_f64.sqrt();
1779        let values = array![
1780            [1.0, 0.0, 0.0],
1781            [inv, 0.1 * inv, 0.0],
1782            [inv, 0.0, -0.1 * inv],
1783            [inv, -0.1 * inv, 0.0],
1784        ];
1785        let manifold = ResponseManifold::Stiefel { k: 1, n: 3 };
1786        let tol = 1.0e-10;
1787        let mean = response_frechet_mean(manifold, values.view(), None, tol, 256)
1788            .expect("tight sphere cloud must reach the Karcher certificate");
1789
1790        assert_eq!(mean.len(), 3);
1791        let nrm = (mean[0] * mean[0] + mean[1] * mean[1] + mean[2] * mean[2]).sqrt();
1792        assert!(
1793            (nrm - 1.0).abs() < 1e-9,
1794            "mean must be unit-norm, got {nrm}"
1795        );
1796        let residual = frechet_residual(manifold, values.view(), mean.view());
1797        assert!(
1798            residual <= tol,
1799            "successful mean residual {residual:.3e} exceeds tolerance {tol:.3e}"
1800        );
1801    }
1802
1803    #[test]
1804    fn budget_exhausted_generic_frechet_is_typed_non_convergence() {
1805        let values = fibonacci_sphere(60);
1806        for manifold in [
1807            ResponseManifold::Stiefel { k: 1, n: 3 },
1808            ResponseManifold::Grassmann { k: 1, n: 3 },
1809        ] {
1810            match response_frechet_mean(manifold, values.view(), None, 1.0e-30, 0) {
1811                Err(GeometryError::NonConvergence {
1812                    context,
1813                    iterations,
1814                    residual,
1815                    tolerance,
1816                }) => {
1817                    assert_eq!(context, "response geometry Fréchet mean");
1818                    assert_eq!(iterations, 0);
1819                    assert!(residual.is_finite() && residual > tolerance);
1820                }
1821                other => panic!("{manifold:?} expected typed exhaustion, got {other:?}"),
1822            }
1823        }
1824    }
1825
1826    #[test]
1827    fn frechet_global_uniqueness_radii_are_geometry_derived() {
1828        assert_eq!(
1829            ResponseManifold::Spd { n: 2 }.frechet_uniqueness_radius(),
1830            None
1831        );
1832        assert_eq!(
1833            ResponseManifold::Poincare {
1834                dim: 2,
1835                curvature: -1.0
1836            }
1837            .frechet_uniqueness_radius(),
1838            None
1839        );
1840        assert_eq!(
1841            ResponseManifold::Stiefel { k: 1, n: 3 }.frechet_uniqueness_radius(),
1842            Some(std::f64::consts::FRAC_PI_4)
1843        );
1844        assert_eq!(
1845            ResponseManifold::Grassmann { k: 2, n: 4 }.frechet_uniqueness_radius(),
1846            Some(std::f64::consts::PI / (4.0 * 2.0_f64.sqrt()))
1847        );
1848        assert_eq!(
1849            ResponseManifold::ConstantCurvature { dim: 2, kappa: 4.0 }.frechet_uniqueness_radius(),
1850            Some(std::f64::consts::PI / 8.0)
1851        );
1852        assert_eq!(
1853            ResponseManifold::ConstantCurvature {
1854                dim: 2,
1855                kappa: -3.0
1856            }
1857            .frechet_uniqueness_radius(),
1858            None
1859        );
1860
1861        // A tight SPD cluster still converges to the unique Hadamard mean.
1862        let values = array![
1863            [2.0, 0.0, 0.0, 1.0],
1864            [2.1, 0.05, 0.05, 1.02],
1865            [1.95, -0.03, -0.03, 0.98],
1866        ];
1867        let mean = response_frechet_mean(
1868            ResponseManifold::Spd { n: 2 },
1869            values.view(),
1870            None,
1871            1e-12,
1872            500,
1873        )
1874        .expect("SPD cluster must converge");
1875        assert!(mean.iter().all(|c| c.is_finite()));
1876    }
1877
1878    #[test]
1879    fn diffuse_positive_curvature_cloud_has_typed_global_certificate_error() {
1880        let manifold = ResponseManifold::Stiefel { k: 1, n: 2 };
1881        let angle = 0.9_f64;
1882        let values = array![[angle.cos(), -angle.sin()], [angle.cos(), angle.sin()],];
1883        for cloud in [
1884            values.clone(),
1885            values.slice(ndarray::s![..;-1, ..]).to_owned(),
1886        ] {
1887            match response_frechet_mean(manifold, cloud.view(), None, 1.0e-12, 256) {
1888                Err(GeometryError::FrechetMeanNotGloballyCertified {
1889                    stationarity_residual,
1890                    tolerance,
1891                    support_radius,
1892                    uniqueness_radius,
1893                    ..
1894                }) => {
1895                    assert!(stationarity_residual <= tolerance);
1896                    assert!(support_radius >= uniqueness_radius);
1897                    assert_eq!(uniqueness_radius, std::f64::consts::FRAC_PI_4);
1898                }
1899                other => panic!("expected diffuse-cloud certificate error, got {other:?}"),
1900            }
1901        }
1902    }
1903
1904    #[test]
1905    fn tight_positive_curvature_mean_is_permutation_invariant_beyond_eight_rows() {
1906        let manifold = ResponseManifold::Stiefel { k: 1, n: 2 };
1907        let angles = [
1908            -0.20_f64, -0.16, -0.12, -0.08, -0.04, 0.0, 0.03, 0.06, 0.09, 0.12, 0.15, 0.18,
1909        ];
1910        let mut values = Array2::<f64>::zeros((angles.len(), 2));
1911        for (row, angle) in angles.into_iter().enumerate() {
1912            values[[row, 0]] = angle.cos();
1913            values[[row, 1]] = angle.sin();
1914        }
1915        let reversed = values.slice(ndarray::s![..;-1, ..]).to_owned();
1916        let direct = response_frechet_mean(manifold, values.view(), None, 1.0e-12, 256)
1917            .expect("tight cloud has a certified global mean");
1918        let permuted = response_frechet_mean(manifold, reversed.view(), None, 1.0e-12, 256)
1919            .expect("permuted tight cloud has a certified global mean");
1920        // THE ONE BOUND IN THIS FILE BEING LOOSENED, deliberately. 1.0e-12 was
1921        // EXACTLY the tolerance both runs above were solved to, and a bound at
1922        // the solver's own tolerance is not strict -- it is wrong. Each run may
1923        // stop anywhere inside the ‖grad‖ ≤ 1e-12 stationarity ball, so two
1924        // independently converged runs can legitimately differ by ~2× tol in
1925        // gradient, and by more than that in displacement once the 1/κ
1926        // curvature factor is applied. As written this is a live flake, not a
1927        // check.
1928        //
1929        // Bound source: 100× the solver tolerance (1e-12) named on the two
1930        // `response_frechet_mean` calls above. Still orders below any real
1931        // permutation asymmetry, which would be O(the descent step), ~1e-2.
1932        assert!(
1933            (&direct - &permuted)
1934                .iter()
1935                .all(|value| value.abs() <= 1.0e-10)
1936        );
1937        assert!(frechet_residual(manifold, values.view(), direct.view()) <= 1.0e-12);
1938    }
1939
1940    #[test]
1941    fn zero_weight_cut_locus_rows_do_not_affect_mean_or_certificate() {
1942        let manifold = ResponseManifold::Stiefel { k: 1, n: 2 };
1943        let values = array![[1.0, 0.0], [-1.0, 0.0]];
1944        let weights = array![1.0, 0.0];
1945        let mean =
1946            response_frechet_mean(manifold, values.view(), Some(weights.view()), 1.0e-12, 32)
1947                .expect("zero-mass cut-locus row must be ignored");
1948        assert!(
1949            (&mean - &values.row(0))
1950                .iter()
1951                .all(|value| value.abs() <= f64::EPSILON)
1952        );
1953    }
1954
1955    #[test]
1956    fn resolver_rejects_bad_shapes() {
1957        assert!(ResponseManifold::resolve("grassmann", Some(2), Some(3), None, None).is_err());
1958        assert!(ResponseManifold::resolve("spd", None, None, None, None).is_err());
1959        assert!(ResponseManifold::resolve("poincare", None, None, Some(2), Some(1.0)).is_err());
1960        assert!(ResponseManifold::resolve("nonsense", None, None, None, None).is_err());
1961        assert_eq!(
1962            ResponseManifold::resolve("spd", Some(3), None, None, None).unwrap(),
1963            ResponseManifold::Spd { n: 3 }
1964        );
1965    }
1966
1967    #[test]
1968    fn parse_infers_shapes_from_columns() {
1969        // SPD: n from the perfect-square column count.
1970        assert_eq!(
1971            ResponseManifold::parse("spd", 9).unwrap(),
1972            ResponseManifold::Spd { n: 3 }
1973        );
1974        assert!(ResponseManifold::parse("spd", 8).is_err());
1975        // Grassmann/Stiefel: n inferred as cols / k.
1976        assert_eq!(
1977            ResponseManifold::parse("grassmann(k=2)", 10).unwrap(),
1978            ResponseManifold::Grassmann { k: 2, n: 5 }
1979        );
1980        assert_eq!(
1981            ResponseManifold::parse("Stiefel( k = 2 , n = 4 )", 8).unwrap(),
1982            ResponseManifold::Stiefel { k: 2, n: 4 }
1983        );
1984        assert!(ResponseManifold::parse("grassmann", 10).is_err());
1985        assert!(ResponseManifold::parse("grassmann(k=3)", 10).is_err());
1986        // Poincaré: dim = cols, default curvature -1.
1987        assert_eq!(
1988            ResponseManifold::parse("poincare", 3).unwrap(),
1989            ResponseManifold::Poincare {
1990                dim: 3,
1991                curvature: -1.0
1992            }
1993        );
1994        assert_eq!(
1995            ResponseManifold::parse("poincare(curvature=-0.5)", 3).unwrap(),
1996            ResponseManifold::Poincare {
1997                dim: 3,
1998                curvature: -0.5
1999            }
2000        );
2001        assert!(ResponseManifold::parse("hyperbolic", 3).is_err());
2002    }
2003
2004    #[test]
2005    fn dispatch_round_trips_through_user_label() {
2006        // Drive the full string-selected user path for each geometry: parse the
2007        // label, build the intrinsic base, log to the tangent, exp back.
2008        let cases: Vec<(&str, Array2<f64>)> = vec![
2009            (
2010                "spd",
2011                array![
2012                    [2.0, 0.0, 0.0, 1.0],
2013                    [1.0, 0.3, 0.3, 2.0],
2014                    [3.0, -0.5, -0.5, 1.5],
2015                ],
2016            ),
2017            (
2018                "grassmann(k=1)",
2019                array![
2020                    [1.0, 0.0, 0.0],
2021                    [0.2_f64.cos(), 0.2_f64.sin(), 0.0],
2022                    [0.35_f64.cos(), 0.35_f64.sin(), 0.0],
2023                ],
2024            ),
2025            (
2026                "stiefel(k=1)",
2027                array![
2028                    [1.0, 0.0, 0.0],
2029                    [0.2_f64.cos(), 0.2_f64.sin(), 0.0],
2030                    [0.3_f64.cos(), 0.0, 0.3_f64.sin()],
2031                ],
2032            ),
2033            ("poincare", array![[0.1, 0.2], [-0.3, 0.1], [0.2, -0.25]]),
2034        ];
2035        for (label, values) in cases {
2036            let (tangent, base, canonical) =
2037                dispatch_log_map(values.view(), label, None, None).expect("dispatch log");
2038            assert!(canonical.starts_with(label.split('(').next().unwrap()));
2039            let back = dispatch_exp_map(tangent.view(), label, base.view()).expect("dispatch exp");
2040            for row in 0..values.nrows() {
2041                for col in 0..values.ncols() {
2042                    // Same involution, same manifolds, same bound source as the
2043                    // `round_trip` helper above: exp∘log at a FIXED base is
2044                    // exact in reals, so the achievable residual is the ulp
2045                    // scale of the maps -- a few eps ~ 1e-15 for the closed-form
2046                    // ones, and 1e-13 for the one iterative logarithm (Stiefel
2047                    // k >= 2, whose own inner gate is TOL = 1.0e-13). 1e-11 is
2048                    // 100x that gate.
2049                    //
2050                    // This site was left at 1e-6 when the helper was tightened,
2051                    // and named as outstanding in that commit rather than
2052                    // silently skipped. Nothing about the dispatch wrapper makes
2053                    // it looser than the direct call it forwards to.
2054                    assert!(
2055                        (back[[row, col]] - values[[row, col]]).abs() < 1e-11,
2056                        "{label} exp∘log mismatch at ({row},{col}): {} vs {}",
2057                        back[[row, col]],
2058                        values[[row, col]]
2059                    );
2060                }
2061            }
2062        }
2063    }
2064
2065    #[test]
2066    fn ambient_dim_matches_layout() {
2067        assert_eq!(ResponseManifold::Spd { n: 3 }.ambient_dim(), 9);
2068        assert_eq!(ResponseManifold::Grassmann { k: 2, n: 5 }.ambient_dim(), 10);
2069        assert_eq!(ResponseManifold::Stiefel { k: 2, n: 4 }.ambient_dim(), 8);
2070        assert_eq!(
2071            ResponseManifold::Poincare {
2072                dim: 4,
2073                curvature: -1.0
2074            }
2075            .ambient_dim(),
2076            4
2077        );
2078    }
2079
2080    /// #2125: a weighted response-geometry fit must linearize around the
2081    /// *weighted* Fréchet mean. `dispatch_log_map` picks the tangent base point;
2082    /// before the fix it hard-passed `None` for the weights, so the chart origin
2083    /// was the unweighted intrinsic mean even when the tangent regression was
2084    /// weighted — a biased linearization. Here Stiefel(k=1,n=3) is the sphere S²:
2085    /// two separated clusters, both inside the certified convexity ball, have
2086    /// weights concentrated on the first cluster and must move the base toward it.
2087    #[test]
2088    fn dispatch_log_map_uses_weighted_frechet_mean() {
2089        let a = 0.05_f64;
2090        let separation = 0.6_f64;
2091        // Two clusters on the great circle z = 0: cluster A about [1,0,0]
2092        // (rows 0,1) and cluster B `separation` radians away (rows 2,3).
2093        // Every row is an exact unit vector (cos²+sin²=1).
2094        let values = array![
2095            [a.cos(), a.sin(), 0.0],
2096            [(-a).cos(), (-a).sin(), 0.0],
2097            [(separation - a).cos(), (separation - a).sin(), 0.0],
2098            [(separation + a).cos(), (separation + a).sin(), 0.0],
2099        ];
2100        // Heavily weight cluster A: the weighted mean must sit near [1,0,0],
2101        // whereas the unweighted mean sits near the 45° bisector.
2102        let weights = array![50.0_f64, 50.0, 1.0, 1.0];
2103        let manifold = ResponseManifold::Stiefel { k: 1, n: 3 };
2104
2105        let geodesic = |u: ArrayView1<'_, f64>, v: ArrayView1<'_, f64>| -> f64 {
2106            u.dot(&v).clamp(-1.0, 1.0).acos()
2107        };
2108
2109        let unweighted_ref =
2110            response_frechet_mean(manifold, values.view(), None, 1e-12, 256).expect("unweighted");
2111        let weighted_ref =
2112            response_frechet_mean(manifold, values.view(), Some(weights.view()), 1e-12, 256)
2113                .expect("weighted");
2114        // Sanity: the two intrinsic means genuinely differ, so this design can
2115        // distinguish a weighted from an unweighted base point.
2116        assert!(
2117            geodesic(unweighted_ref.view(), weighted_ref.view()) > 0.2,
2118            "test design degenerate: weighted and unweighted means nearly coincide"
2119        );
2120
2121        let (_t_uw, base_uw, _c) =
2122            dispatch_log_map(values.view(), "stiefel(k=1)", None, None).expect("unweighted chart");
2123        let (_t_w, base_w, _c) =
2124            dispatch_log_map(values.view(), "stiefel(k=1)", None, Some(weights.view()))
2125                .expect("weighted chart");
2126
2127        // (a) Supplying weights must change the base point (before the fix the
2128        // weighted chart origin was byte-identical to the unweighted one).
2129        let moved = base_w
2130            .iter()
2131            .zip(base_uw.iter())
2132            .any(|(w, u)| (w - u).abs() > 1e-9);
2133        assert!(
2134            moved,
2135            "weighted base point is identical to the unweighted one: weights ignored"
2136        );
2137
2138        // (b) The weighted base point must be closer to the WEIGHTED Fréchet
2139        // mean than to the unweighted one.
2140        let d_to_weighted = geodesic(base_w.view(), weighted_ref.view());
2141        let d_to_unweighted = geodesic(base_w.view(), unweighted_ref.view());
2142        assert!(
2143            d_to_weighted < d_to_unweighted,
2144            "weighted base point is nearer the unweighted mean ({d_to_unweighted}) \
2145             than the weighted mean ({d_to_weighted})"
2146        );
2147        // And it should essentially coincide with the weighted mean.
2148        assert!(
2149            d_to_weighted < 1e-6,
2150            "weighted base point is {d_to_weighted} from the weighted Fréchet mean"
2151        );
2152    }
2153
2154    /// Deterministic xorshift64* + Box–Muller standard normals — a dependency-free
2155    /// reproducible source for the synthetic known-κ clouds. Seeded per call so
2156    /// the test is bit-stable across runs and platforms.
2157    struct DetNormal {
2158        state: u64,
2159        spare: Option<f64>,
2160    }
2161    impl DetNormal {
2162        fn new(seed: u64) -> Self {
2163            Self {
2164                state: seed | 1,
2165                spare: None,
2166            }
2167        }
2168        fn u01(&mut self) -> f64 {
2169            // xorshift64*; take the top 53 bits as a (0,1) double.
2170            let mut x = self.state;
2171            x ^= x >> 12;
2172            x ^= x << 25;
2173            x ^= x >> 27;
2174            self.state = x;
2175            let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
2176            ((v >> 11) as f64 + 0.5) / (1u64 << 53) as f64
2177        }
2178        fn normal(&mut self) -> f64 {
2179            if let Some(z) = self.spare.take() {
2180                return z;
2181            }
2182            // Box–Muller; clamp u1 away from 0 so ln is finite.
2183            let u1 = self.u01().max(1e-12);
2184            let u2 = self.u01();
2185            let r = (-2.0 * u1.ln()).sqrt();
2186            let theta = 2.0 * std::f64::consts::PI * u2;
2187            self.spare = Some(r * theta.sin());
2188            r * theta.cos()
2189        }
2190    }
2191
2192    /// Build a synthetic cloud at known curvature `k_star`: `n` points whose
2193    /// geodesic normal coordinates about `center` are i.i.d. isotropic Gaussian
2194    /// of scale `sigma`, exp-mapped onto `M_{k_star}`, then mean-centred in the
2195    /// ambient chart to mimic the real (mean-subtracted) response clouds.
2196    fn synth_cloud(dim: usize, k_star: f64, n: usize, sigma: f64, seed: u64) -> Array2<f64> {
2197        let manifold = ResponseManifold::ConstantCurvature { dim, kappa: k_star };
2198        let center = Array1::<f64>::zeros(dim);
2199        let mut rng = DetNormal::new(seed);
2200        let mut values = Array2::<f64>::zeros((n, dim));
2201        for i in 0..n {
2202            let t: Array1<f64> = (0..dim).map(|_| sigma * rng.normal()).collect();
2203            let y = manifold
2204                .exp_point(center.view(), t.view())
2205                .expect("exp tangent to response");
2206            values.row_mut(i).assign(&y);
2207        }
2208        // Mean-centre in the ambient chart (the real-data preprocessing).
2209        let mut mean = Array1::<f64>::zeros(dim);
2210        for row in values.outer_iter() {
2211            mean += &row;
2212        }
2213        mean.mapv_inplace(|v| v / n as f64);
2214        for mut row in values.outer_iter_mut() {
2215            row -= &mean;
2216        }
2217        values
2218    }
2219
2220    #[test]
2221    fn response_curvature_criterion_jet_matches_finite_difference_oracle() {
2222        // Test-only central differences verify the hand-derived score and
2223        // Hessian on both sides of the flat member. Production fitting uses
2224        // only `response_curvature_criterion_jet`.
2225        let values = array![
2226            [0.18, -0.07],
2227            [-0.11, 0.16],
2228            [0.04, 0.21],
2229            [-0.15, -0.09],
2230            [0.09, -0.13],
2231        ];
2232        let h = 1.0e-5;
2233        for kappa in [-0.8, 0.0, 0.9] {
2234            let jet = response_curvature_criterion_jet(values.view(), 2, kappa)
2235                .expect("analytic curvature jet");
2236            let plus = response_curvature_criterion_jet(values.view(), 2, kappa + h)
2237                .expect("positive finite-difference probe");
2238            let minus = response_curvature_criterion_jet(values.view(), 2, kappa - h)
2239                .expect("negative finite-difference probe");
2240            let score_fd = (plus.value - minus.value) / (2.0 * h);
2241            let curvature_fd = (plus.score - minus.score) / (2.0 * h);
2242            let score_scale = 1.0 + jet.score.abs().max(score_fd.abs());
2243            let curvature_scale = 1.0 + jet.curvature.abs().max(curvature_fd.abs());
2244            assert!(
2245                (jet.score - score_fd).abs() <= 2.0e-8 * score_scale,
2246                "kappa={kappa}: analytic score {} != FD {score_fd}",
2247                jet.score
2248            );
2249            assert!(
2250                (jet.curvature - curvature_fd).abs() <= 2.0e-8 * curvature_scale,
2251                "kappa={kappa}: analytic curvature {} != FD {curvature_fd}",
2252                jet.curvature
2253            );
2254        }
2255    }
2256
2257    #[test]
2258    fn response_curvature_budget_exhaustion_is_typed_non_convergence() {
2259        let values = synth_cloud(3, 0.8, 80, 0.15, 0xC0A7_2247);
2260        match fit_response_curvature(values.view(), 3, 0.95, 1.0e-14, 0) {
2261            Err(ResponseGeometryError::CurvatureNonConvergence {
2262                iterations,
2263                max_iter,
2264                kkt_residual,
2265                tolerance,
2266                score,
2267                curvature,
2268                ..
2269            }) => {
2270                assert_eq!(iterations, 0);
2271                assert_eq!(max_iter, 0);
2272                assert!(kkt_residual.is_finite() && kkt_residual > tolerance);
2273                assert!(score.is_finite() && curvature.is_finite());
2274            }
2275            other => panic!("expected typed curvature exhaustion, got {other:?}"),
2276        }
2277    }
2278
2279    /// The #1104 reparameterisation-invariant curvature estimator: on synthetic
2280    /// clouds generated at known κ⋆ the fitted κ̂ must be (a) INTERIOR to the
2281    /// chart bracket (never railed), (b) close to κ⋆ and MONOTONE in κ⋆, (c)
2282    /// produce a smooth (non-degenerate) χ²₁ flatness p-value that does not reject
2283    /// the flat truth, and (d) be correctly COVARIANT under a global rescaling of
2284    /// the cloud (κ has units 1/length², so `y ↦ α y ⇒ κ̂ ↦ κ̂/α²`).
2285    #[test]
2286    fn fit_response_curvature_is_reparameterization_invariant() {
2287        let dim = 3usize;
2288        // Unit-ish scale: σ=0.15 keeps every geodesic radius (≈ a few·σ) well
2289        // inside the κ-stereographic chart for the most hyperbolic κ⋆ = −1.5
2290        // (chart needs ‖y‖² < 1/1.5 ≈ 0.667).
2291        let sigma = 0.15;
2292        let n = 300usize;
2293        let k_stars = [-1.5_f64, -0.5, 0.0, 0.6, 1.2];
2294        let mut k_hats = Vec::new();
2295        for (idx, &k_star) in k_stars.iter().enumerate() {
2296            let values = synth_cloud(dim, k_star, n, sigma, 0xC0FFEE ^ (idx as u64 + 1));
2297            let (kmin, kmax, _rho) = response_kappa_bounds(values.view());
2298            let fit = fit_response_curvature(values.view(), dim, 0.95, 1e-12, 256)
2299                .expect("response curvature fit");
2300            k_hats.push(fit.kappa_hat);
2301
2302            // (a) INTERIOR: κ̂ strictly inside the bracket, not railed to either end.
2303            let span = kmax - kmin;
2304            assert!(
2305                fit.kappa_hat > kmin + 0.02 * span && fit.kappa_hat < kmax - 0.02 * span,
2306                "κ⋆={k_star}: κ̂={} railed to bracket [{kmin}, {kmax}]",
2307                fit.kappa_hat
2308            );
2309
2310            // (b-direct) recovery within a sane tolerance (finite-sample bias is
2311            // O(1/n); the estimator only needs the right region and sign).
2312            assert!(
2313                (fit.kappa_hat - k_star).abs() <= 0.6 + 0.3 * k_star.abs(),
2314                "κ⋆={k_star}: κ̂={} too far",
2315                fit.kappa_hat
2316            );
2317
2318            // (c) the profile CI is a valid interval bracketing κ̂.
2319            assert!(
2320                fit.profile_ci.ci_lo <= fit.kappa_hat && fit.kappa_hat <= fit.profile_ci.ci_hi,
2321                "κ⋆={k_star}: CI [{}, {}] excludes κ̂={}",
2322                fit.profile_ci.ci_lo,
2323                fit.profile_ci.ci_hi,
2324                fit.kappa_hat
2325            );
2326            // The flatness LR statistic and p-value are valid; the p-value is a
2327            // genuine probability strictly between 0 and 1 (smooth, not 0/1).
2328            assert!(fit.flatness.lr_stat >= 0.0);
2329            assert!(
2330                fit.flatness.p_value > 0.0 && fit.flatness.p_value < 1.0,
2331                "κ⋆={k_star}: degenerate flatness p={}",
2332                fit.flatness.p_value
2333            );
2334            // The flat truth κ⋆ = 0 must NOT be rejected at 5% (lr < χ²_{1,.95}).
2335            if k_star == 0.0 {
2336                assert!(
2337                    fit.flatness.lr_stat < 3.84,
2338                    "flat truth wrongly rejected: lr={}",
2339                    fit.flatness.lr_stat
2340                );
2341            }
2342
2343            // (d) RESCALING COVARIANCE: scale the SAME cloud by α and refit; κ̂
2344            // must transform as κ̂/α² (curvature has units 1/length²). We reuse the
2345            // identical points so the only change is the global scale.
2346            let alpha = 1.5_f64;
2347            let scaled = values.mapv(|v| alpha * v);
2348            let fit_scaled = fit_response_curvature(scaled.view(), dim, 0.95, 1e-12, 256)
2349                .expect("scaled response curvature fit");
2350            let expected = fit.kappa_hat / (alpha * alpha);
2351            // Tolerance scales with magnitude; the transform is exact in the
2352            // criterion (V(κ, αy) = V(α²κ, y)) up to the analytic score
2353            // solve's floating-point tolerance.
2354            assert!(
2355                (fit_scaled.kappa_hat - expected).abs() <= 0.05 + 0.05 * expected.abs(),
2356                "κ⋆={k_star}: rescale covariance broken: κ̂(αy)={} vs κ̂(y)/α²={}",
2357                fit_scaled.kappa_hat,
2358                expected
2359            );
2360        }
2361
2362        // (b-monotone) κ̂ is monotone increasing in κ⋆ across the whole sweep.
2363        for w in k_hats.windows(2) {
2364            assert!(w[1] > w[0] - 0.05, "κ̂ not monotone in κ⋆: {:?}", k_hats);
2365        }
2366
2367        // (e) TRANSLATION INVARIANCE (#2351): a rigid ambient translation is a
2368        // no-op for the cloud's intrinsic shape, so κ̂, the verdict, the
2369        // scale-free invariant, and both rail flags must be unchanged to
2370        // numerical identity. This is the direct regression guard for the
2371        // ambient-origin κ_min/conformal-term bug.
2372        let values = synth_cloud(dim, 0.6, n, sigma, 0xC0FFEE ^ 4);
2373        let fit =
2374            fit_response_curvature(values.view(), dim, 0.95, 1e-12, 256).expect("untranslated fit");
2375        let shifted = &values + 10.0;
2376        let fit_shifted =
2377            fit_response_curvature(shifted.view(), dim, 0.95, 1e-12, 256).expect("translated fit");
2378        assert!(
2379            (fit.kappa_hat - fit_shifted.kappa_hat).abs() <= 1.0e-9 * (1.0 + fit.kappa_hat.abs()),
2380            "κ̂ moved under pure translation: {} vs {}",
2381            fit.kappa_hat,
2382            fit_shifted.kappa_hat
2383        );
2384        assert_eq!(fit.profile_ci.verdict, fit_shifted.profile_ci.verdict);
2385        assert!((fit.kappa_r2 - fit_shifted.kappa_r2).abs() <= 1.0e-9 * (1.0 + fit.kappa_r2.abs()));
2386        assert_eq!(
2387            fit.railed_at_resolution_limit,
2388            fit_shifted.railed_at_resolution_limit
2389        );
2390        assert_eq!(
2391            fit.railed_at_hyperbolic_resolution_limit,
2392            fit_shifted.railed_at_hyperbolic_resolution_limit
2393        );
2394    }
2395
2396    /// d = 1 carries REDUCED curvature information: the transverse volume
2397    /// Jacobian is identically 1 (radial isometry), so κ is identified by the
2398    /// conformal-factor restoring force `−d·Σ ln λ_{y_i}` alone (#944 power
2399    /// analysis). The estimator must still run end-to-end, return an INTERIOR
2400    /// κ̂, and produce a valid CI — never divide/exponentiate the absent
2401    /// transverse direction.
2402    #[test]
2403    fn fit_response_curvature_d1_uses_conformal_term_only() {
2404        let sigma = 0.12;
2405        let n = 400usize;
2406        for &k_star in &[-1.0_f64, 0.0, 0.8] {
2407            let values = synth_cloud(1, k_star, n, sigma, 0xD1 ^ (k_star.to_bits()));
2408            let (kmin, kmax, _rho) = response_kappa_bounds(values.view());
2409            let fit = fit_response_curvature(values.view(), 1, 0.95, 1e-12, 256)
2410                .expect("d=1 curvature fit");
2411            let span = kmax - kmin;
2412            assert!(
2413                fit.kappa_hat > kmin + 0.01 * span && fit.kappa_hat < kmax - 0.01 * span,
2414                "d=1 κ⋆={k_star}: κ̂={} railed to [{kmin},{kmax}]",
2415                fit.kappa_hat
2416            );
2417            assert!(
2418                fit.profile_ci.ci_lo <= fit.kappa_hat && fit.kappa_hat <= fit.profile_ci.ci_hi,
2419                "d=1 κ⋆={k_star}: CI excludes κ̂"
2420            );
2421            assert!(fit.kappa_hat.is_finite() && fit.v_p_hat.is_finite());
2422        }
2423    }
2424
2425    /// The criterion guard must reject κ probes AT or PAST the chart boundary
2426    /// gracefully (an `Err`, never a panic / NaN): on the hyperbolic edge
2427    /// `1 + κ‖y‖² ≤ 0` and on the spherical antipode. The `response_kappa_bounds`
2428    /// bracket stays strictly interior, but a stray CI/LR probe can land on the
2429    /// edge, so the criterion itself must be defensive.
2430    #[test]
2431    fn response_curvature_criterion_rejects_boundary_probes() {
2432        // #2351: the chart evaluates on mean-centred coordinates, so the
2433        // hyperbolic edge is κ = −1/max‖y−μ‖² (centroid-relative spread).
2434        let values = array![[0.5_f64, 0.0], [-0.4, 0.3], [0.1, -0.5]];
2435        let centroid = {
2436            let mut c = Array1::<f64>::zeros(2);
2437            for row in values.outer_iter() {
2438                c += &row;
2439            }
2440            c.mapv(|v| v / values.nrows() as f64)
2441        };
2442        let s2_max = values
2443            .outer_iter()
2444            .map(|r| {
2445                let z = &r - &centroid;
2446                z.dot(&z)
2447            })
2448            .fold(0.0_f64, f64::max);
2449        // Exactly on / past the hyperbolic edge: 1 + κ‖y−μ‖² = 0 (or < 0).
2450        let kappa_edge = -1.0 / s2_max;
2451        assert!(
2452            response_curvature_criterion(values.view(), 2, kappa_edge).is_err(),
2453            "criterion must reject the hyperbolic chart edge κ=−1/R²"
2454        );
2455        assert!(
2456            response_curvature_criterion(values.view(), 2, 1.5 * kappa_edge).is_err(),
2457            "criterion must reject past the hyperbolic chart edge"
2458        );
2459        // Interior κ just inside the edge succeeds and is finite.
2460        let (v, _) = response_curvature_criterion(values.view(), 2, 0.9 * kappa_edge)
2461            .expect("interior κ valid");
2462        assert!(v.is_finite());
2463        // Non-finite κ is rejected up front.
2464        assert!(response_curvature_criterion(values.view(), 2, f64::NAN).is_err());
2465        assert!(response_curvature_criterion(values.view(), 2, f64::INFINITY).is_err());
2466    }
2467
2468    // ── Projection residual (distance to candidate manifold) ───────────────
2469
2470    #[test]
2471    fn projection_residual_is_zero_for_on_manifold_points() {
2472        // On-manifold rows are their own nearest point, so the residual is ~0
2473        // row-wise. No base point / Fréchet mean is involved — projection is
2474        // base-independent — so this no longer depends on the inputs forming an
2475        // admissible Karcher seed.
2476        let cases: Vec<(ResponseManifold, Array2<f64>)> = vec![
2477            (
2478                ResponseManifold::Spd { n: 2 }, // PD: eigenvalues {2,1} and {2,1}
2479                array![[2.0, 0.0, 0.0, 1.0], [1.5, 0.5, 0.5, 1.5]],
2480            ),
2481            (
2482                ResponseManifold::Grassmann { k: 1, n: 3 }, // unit columns
2483                array![[1.0, 0.0, 0.0], [0.6, 0.8, 0.0]],
2484            ),
2485            (
2486                ResponseManifold::Poincare {
2487                    dim: 2,
2488                    curvature: -1.0,
2489                }, // strictly inside the ball
2490                array![[0.1, 0.2], [-0.3, 0.1]],
2491            ),
2492        ];
2493        for (manifold, values) in cases {
2494            let (resid, rel) =
2495                response_projection_residual(manifold, values.view()).expect("projection residual");
2496            for row in 0..values.nrows() {
2497                assert!(
2498                    resid[row] < 1e-9,
2499                    "{manifold:?} on-manifold row {row} should have ~0 residual, got {}",
2500                    resid[row]
2501                );
2502                assert!(rel[row] < 1e-9 && rel[row] >= 0.0);
2503            }
2504        }
2505    }
2506
2507    #[test]
2508    fn projection_residual_recovers_known_off_manifold_displacement() {
2509        // Closed-form checks against the exact nearest-point distance.
2510
2511        // Gr(1,3) / sphere: nearest unit vector to x is x/‖x‖, so the distance
2512        // is |‖x‖ − 1|. [2,0,0] ⇒ 1; [0,3,0] ⇒ 2. Relative = dist/‖x‖.
2513        let g = ResponseManifold::Grassmann { k: 1, n: 3 };
2514        let gv = array![[2.0, 0.0, 0.0], [0.0, 3.0, 0.0]];
2515        let (gres, grel) = response_projection_residual(g, gv.view()).expect("grassmann");
2516        assert!((gres[0] - 1.0).abs() < 1e-12, "got {}", gres[0]);
2517        assert!((gres[1] - 2.0).abs() < 1e-12, "got {}", gres[1]);
2518        assert!((grel[0] - 0.5).abs() < 1e-12);
2519        assert!((grel[1] - 2.0 / 3.0).abs() < 1e-12);
2520
2521        // SPD(2): nearest PSD matrix clamps negative eigenvalues to 0, so the
2522        // distance is the norm of the discarded negative part. [[1,0],[0,-1]]
2523        // has eigenvalue −1 discarded ⇒ distance 1; ‖x‖_F = √2.
2524        let s = ResponseManifold::Spd { n: 2 };
2525        let sv = array![[1.0, 0.0, 0.0, -1.0]];
2526        let (sres, srel) = response_projection_residual(s, sv.view()).expect("spd");
2527        assert!((sres[0] - 1.0).abs() < 1e-9, "got {}", sres[0]);
2528        assert!((srel[0] - 1.0 / 2.0_f64.sqrt()).abs() < 1e-9);
2529
2530        // Poincaré ball (c = −1, true radius R = 1): the distance to the open
2531        // ball is max(0, ‖x‖ − R). [3,0] ⇒ exactly 2 (not 3 − (1 − BOUNDARY_EPS)
2532        // — the diagnostic uses the manifold radius, not the safety radius).
2533        let p = ResponseManifold::Poincare {
2534            dim: 2,
2535            curvature: -1.0,
2536        };
2537        let pv = array![[3.0, 0.0]];
2538        let (pres, _prel) = response_projection_residual(p, pv.view()).expect("poincare");
2539        assert!((pres[0] - 2.0).abs() < 1e-12, "got {}", pres[0]);
2540
2541        // A different curvature (c = −4, R = 1/2): [2,0] ⇒ 2 − 0.5 = 1.5.
2542        let p4 = ResponseManifold::Poincare {
2543            dim: 2,
2544            curvature: -4.0,
2545        };
2546        let (p4res, _) =
2547            response_projection_residual(p4, array![[2.0, 0.0]].view()).expect("poincare c=-4");
2548        assert!((p4res[0] - 1.5).abs() < 1e-12, "got {}", p4res[0]);
2549    }
2550
2551    #[test]
2552    fn projection_residual_validates_shapes_and_finiteness() {
2553        let manifold = ResponseManifold::Spd { n: 2 }; // ambient = 4
2554        // Wrong column count.
2555        let bad_cols = array![[1.0, 2.0, 3.0]];
2556        assert!(response_projection_residual(manifold, bad_cols.view()).is_err());
2557        // Non-finite value.
2558        let nan_vals = array![[f64::NAN, 0.0, 0.0, 1.0]];
2559        assert!(response_projection_residual(manifold, nan_vals.view()).is_err());
2560        let inf_vals = array![[f64::INFINITY, 0.0, 0.0, 1.0]];
2561        assert!(response_projection_residual(manifold, inf_vals.view()).is_err());
2562    }
2563
2564    #[test]
2565    fn projection_residual_separates_on_and_off_manifold() {
2566        // The motivating case, now honestly answered: an on-manifold row sits
2567        // at zero distance from the candidate shape; a row pushed off it has a
2568        // clearly positive distance. This is the shape-plausibility signal that
2569        // gates which topology is worth fitting — not the post-fit membership
2570        // decision, which comes from the fitted surface's residual instead.
2571        let manifold = ResponseManifold::Grassmann { k: 1, n: 3 };
2572        let on = array![[0.6, 0.8, 0.0]]; // a genuine unit direction
2573        let off = array![[0.6, 0.8, 1.4]]; // same direction, pushed off-sphere
2574
2575        let (resid_on, _) = response_projection_residual(manifold, on.view()).expect("on");
2576        let (resid_off, _) = response_projection_residual(manifold, off.view()).expect("off");
2577
2578        assert!(
2579            resid_on[0] < 1e-9,
2580            "on-manifold should be ~0, got {}",
2581            resid_on[0]
2582        );
2583        assert!(
2584            resid_off[0] > 1e-2 && resid_off[0] > resid_on[0],
2585            "off-manifold distance ({}) must clearly exceed on-manifold ({})",
2586            resid_off[0],
2587            resid_on[0]
2588        );
2589    }
2590
2591    #[test]
2592    fn projection_residual_supports_k_greater_than_one_frames() {
2593        // k > 1 frames use the closed form √Σ(σ_i − 1)². St(2,3), ambient = 6,
2594        // row-major n×k.
2595        let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
2596
2597        // An orthonormal frame [e1 | e2] is its own nearest point ⇒ residual 0.
2598        let on = array![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
2599        let (resid_on, _) = response_projection_residual(manifold, on.view()).expect("on");
2600        assert!(
2601            resid_on[0] < 1e-9,
2602            "orthonormal frame should be ~0, got {}",
2603            resid_on[0]
2604        );
2605
2606        // Scale the first column by 2: Y = [2·e1 | e2]. YᵀY = diag(4,1) ⇒
2607        // σ = (2,1), distance √((2−1)²+(1−1)²) = 1, relative = 1/‖Y‖_F = 1/√5.
2608        let off = array![[2.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
2609        let (resid_off, rel_off) = response_projection_residual(manifold, off.view()).expect("off");
2610        assert!((resid_off[0] - 1.0).abs() < 1e-9, "got {}", resid_off[0]);
2611        assert!(
2612            (rel_off[0] - 1.0 / 5.0_f64.sqrt()).abs() < 1e-9,
2613            "got {}",
2614            rel_off[0]
2615        );
2616
2617        // Grassmann(2,4) gives the identical score for the same frame data.
2618        let g = ResponseManifold::Grassmann { k: 2, n: 4 };
2619        let g_on = array![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]];
2620        let (g_resid, _) = response_projection_residual(g, g_on.view()).expect("grassmann");
2621        assert!(g_resid[0] < 1e-9, "got {}", g_resid[0]);
2622    }
2623
2624    #[test]
2625    fn projection_residual_handles_nontrivial_eigenvectors() {
2626        // A frame whose Gram is NOT diagonal, so the singular values come from a
2627        // genuine eigendecomposition. Y = [[1,1],[0,1],[0,0]] (St(2,3)):
2628        // YᵀY = [[1,1],[1,2]], eigenvalues (3±√5)/2, σ = ((1+√5)/2, (√5−1)/2).
2629        // distance² = (σ₁−1)² + (σ₂−1)².
2630        let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
2631        let y = array![[1.0, 1.0, 0.0, 1.0, 0.0, 0.0]]; // row-major rows [1,1],[0,1],[0,0]
2632        let (resid, _) = response_projection_residual(manifold, y.view()).expect("frame");
2633        let s5 = 5.0_f64.sqrt();
2634        let sig1 = (1.0 + s5) / 2.0;
2635        let sig2 = (s5 - 1.0) / 2.0;
2636        let expect = ((sig1 - 1.0).powi(2) + (sig2 - 1.0).powi(2)).sqrt();
2637        assert!(
2638            (resid[0] - expect).abs() < 1e-9,
2639            "got {} want {}",
2640            resid[0],
2641            expect
2642        );
2643    }
2644
2645    #[test]
2646    fn projection_residual_is_defined_for_rank_deficient_frames() {
2647        // A rank-deficient frame has a well-defined distance even though the
2648        // nearest orthonormal frame is not unique — distance to a compact set is
2649        // always defined, so this must NOT error. Two identical columns e1 give
2650        // YᵀY = [[1,1],[1,1]], σ = (√2, 0), distance √((√2−1)²+(0−1)²) = √(4−2√2).
2651        let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
2652        let degenerate = array![[1.0, 1.0, 0.0, 0.0, 0.0, 0.0]]; // both columns = e1
2653        let (resid, _) =
2654            response_projection_residual(manifold, degenerate.view()).expect("rank-deficient ok");
2655        let expect = (4.0 - 2.0 * 2.0_f64.sqrt()).sqrt(); // ≈ 1.0823922
2656        assert!(
2657            (resid[0] - expect).abs() < 1e-9,
2658            "got {} want {}",
2659            resid[0],
2660            expect
2661        );
2662
2663        // Minimal case: zero vector on the sphere (Gr(1,3)). Every unit vector is
2664        // a nearest point and the distance is exactly 1 — also must not error.
2665        let sphere = ResponseManifold::Grassmann { k: 1, n: 3 };
2666        let (zres, _) =
2667            response_projection_residual(sphere, array![[0.0, 0.0, 0.0]].view()).expect("zero");
2668        assert!((zres[0] - 1.0).abs() < 1e-12, "got {}", zres[0]);
2669    }
2670
2671    #[test]
2672    fn projection_residual_handles_tiny_full_rank_frame() {
2673        // A tiny but full-rank frame must NOT be rejected as rank-deficient: the
2674        // distance is scale-correct. Y = 1e-7·[e1 | e2] (St(2,3)) ⇒ σ = (1e-7,
2675        // 1e-7), distance √2·(1 − 1e-7) ≈ 1.41421342.
2676        let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
2677        let tiny = array![[1e-7, 0.0, 0.0, 1e-7, 0.0, 0.0]];
2678        let (resid, _) = response_projection_residual(manifold, tiny.view()).expect("tiny ok");
2679        let expect = 2.0_f64.sqrt() * (1.0 - 1e-7);
2680        assert!(
2681            (resid[0] - expect).abs() < 1e-9,
2682            "got {} want {}",
2683            resid[0],
2684            expect
2685        );
2686    }
2687
2688    #[test]
2689    fn projection_residual_spd_nonsymmetric_and_singular() {
2690        // Non-symmetric input: A = [[1,1],[-1,1]] has sym(A) = I (no negative
2691        // part), but the distance to the PSD cone still counts the skew part:
2692        // ‖A − I‖_F = √2.
2693        let spd = ResponseManifold::Spd { n: 2 };
2694        let asym = array![[1.0, 1.0, -1.0, 1.0]]; // row-major [[1,1],[-1,1]]
2695        let (ares, _) = response_projection_residual(spd, asym.view()).expect("nonsym");
2696        assert!((ares[0] - 2.0_f64.sqrt()).abs() < 1e-9, "got {}", ares[0]);
2697
2698        // A singular PSD matrix diag(1,0) is in the closed cone ⇒ distance 0
2699        // (even though it is not strictly positive definite).
2700        let singular = array![[1.0, 0.0, 0.0, 0.0]];
2701        let (sres, _) = response_projection_residual(spd, singular.view()).expect("singular psd");
2702        assert!(
2703            sres[0] < 1e-12,
2704            "singular PSD should be ~0, got {}",
2705            sres[0]
2706        );
2707    }
2708
2709    #[test]
2710    fn projection_residual_poincare_interior_shell_is_zero() {
2711        // A point in the numerical safety shell R_safe < ‖x‖ < R is a genuine
2712        // interior point of the manifold ball, so it must score exactly 0 — the
2713        // diagnostic uses the true radius, not the projection safety radius.
2714        let p = ResponseManifold::Poincare {
2715            dim: 2,
2716            curvature: -1.0,
2717        };
2718        let shell = array![[0.999999, 0.0]]; // inside R = 1, outside R_safe ≈ 0.99999
2719        let (resid, _) = response_projection_residual(p, shell.view()).expect("shell");
2720        assert!(
2721            resid[0] < 1e-12,
2722            "interior point must be 0, got {}",
2723            resid[0]
2724        );
2725    }
2726
2727    #[test]
2728    fn projection_residual_handles_constant_curvature_domain() {
2729        // ConstantCurvature is a fittable response geometry produced by the
2730        // resolver/parser, so it must return a closed-form distance, not error.
2731        // κ ≥ 0: chart is all of ℝ^d ⇒ every finite row scores 0.
2732        let pos = ResponseManifold::parse("constant_curvature(dim=3,kappa=1.0)", 3)
2733            .expect("parse constant_curvature");
2734        assert!(matches!(pos, ResponseManifold::ConstantCurvature { .. }));
2735        let (pres, _) =
2736            response_projection_residual(pos, array![[0.1, 9.0, -100.0]].view()).expect("kappa>=0");
2737        assert!(pres[0] < 1e-12, "κ≥0 finite row must be 0, got {}", pres[0]);
2738
2739        // κ < 0: chart is the ball of radius 1/√(−κ). For κ = −1, R = 1, so a
2740        // point of norm 3 is at distance 2; an interior point is at 0.
2741        let neg = ResponseManifold::ConstantCurvature {
2742            dim: 2,
2743            kappa: -1.0,
2744        };
2745        let (nres, _) = response_projection_residual(neg, array![[3.0, 0.0], [0.2, 0.1]].view())
2746            .expect("kappa<0");
2747        assert!((nres[0] - 2.0).abs() < 1e-12, "got {}", nres[0]);
2748        assert!(nres[1] < 1e-12, "interior row must be 0, got {}", nres[1]);
2749    }
2750
2751    #[test]
2752    fn projection_residual_accepts_empty_batch() {
2753        // A zero-row batch is valid and returns empty arrays for every geometry.
2754        let manifold = ResponseManifold::Spd { n: 2 }; // ambient = 4
2755        let empty = Array2::<f64>::zeros((0, 4));
2756        let (resid, rel) = response_projection_residual(manifold, empty.view()).expect("empty");
2757        assert_eq!(resid.len(), 0);
2758        assert_eq!(rel.len(), 0);
2759    }
2760}