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