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 ¶ms {
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 ¶ms {
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 - ¢roid;
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(¢red);
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 - ¢roid;
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}