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//! [`crate::geometry`] 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};
25
26use crate::manifold::{
27 GEOMETRY_EPS, RiemannianManifold, flatten, from_flat, jacobi_symmetric, spectral_map_symmetric,
28 sym,
29};
30use crate::manifolds::constant_curvature::ConstantCurvature;
31use crate::{GeometryError, GeometryResult, GrassmannManifold, SpdManifold, StiefelManifold};
32
33/// Split a parenthesised `key=value, key=value` parameter list into trimmed,
34/// lower-cased `(key, value)` pairs. An empty list is valid (`spd()`).
35fn parse_kv(inner: &str) -> Result<Vec<(String, String)>, String> {
36 let trimmed = inner.trim();
37 if trimmed.is_empty() {
38 return Ok(Vec::new());
39 }
40 let mut out = Vec::new();
41 for piece in trimmed.split(',') {
42 let piece = piece.trim();
43 if piece.is_empty() {
44 continue;
45 }
46 let (k, v) = piece
47 .split_once('=')
48 .ok_or_else(|| format!("response_geometry parameter {piece:?} must be key=value"))?;
49 out.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
50 }
51 Ok(out)
52}
53
54/// A fittable curved response geometry. Each variant carries the shape the user
55/// requested; the embedding/ambient flat dimension is fixed by that shape and
56/// is the column count of the `values` matrix the caller supplies.
57#[derive(Debug, Clone, Copy, PartialEq)]
58pub enum ResponseManifold {
59 /// Symmetric positive-definite `n×n` matrices, flattened row-major to `n²`
60 /// ambient coordinates (the layout [`SpdManifold`] uses).
61 Spd { n: usize },
62 /// `k`-dimensional subspaces of `ℝⁿ`, represented by an orthonormal `n×k`
63 /// frame flattened to `n·k` ambient coordinates.
64 Grassmann { k: usize, n: usize },
65 /// Orthonormal `k`-frames in `ℝⁿ`, flattened to `n·k` ambient coordinates.
66 Stiefel { k: usize, n: usize },
67 /// The Poincaré ball of dimension `d` with curvature `κ < 0`.
68 Poincare { dim: usize, curvature: f64 },
69 /// Constant-curvature manifold `M_κ` of dimension `d` with curvature `κ`
70 /// (any finite real value). `κ > 0` → spherical, `κ = 0` → flat (Euclidean
71 /// up to scale), `κ < 0` → hyperbolic (Poincaré ball). Unlike `Poincare`,
72 /// which fixes `κ < 0`, this variant accepts any curvature including zero
73 /// and positive values, and is the target for curvature-as-estimand fits
74 /// where `κ̂` is optimized over all of ℝ (#1104).
75 ConstantCurvature { dim: usize, kappa: f64 },
76}
77
78impl ResponseManifold {
79 /// Resolve a lower-cased geometry label and its shape parameters into a
80 /// response manifold. Shape parameters are passed positionally exactly as
81 /// the FFI marshals them; absent/zero values are rejected here so the error
82 /// surfaces at selection time rather than mid-fit.
83 ///
84 /// - `"spd"` needs `n` (matrix side).
85 /// - `"grassmann"` / `"stiefel"` need `k` and `n` with `1 ≤ k ≤ n`.
86 /// - `"poincare"` needs `dim` and a strictly negative `curvature`.
87 pub fn resolve(
88 kind: &str,
89 n: Option<usize>,
90 k: Option<usize>,
91 dim: Option<usize>,
92 curvature: Option<f64>,
93 ) -> Result<Self, String> {
94 match kind {
95 "spd" => {
96 let n = n.ok_or_else(|| "response_geometry='spd' requires n".to_string())?;
97 if n == 0 {
98 return Err("response_geometry='spd' requires n >= 1".to_string());
99 }
100 Ok(Self::Spd { n })
101 }
102 "grassmann" => {
103 let k = k.ok_or_else(|| "response_geometry='grassmann' requires k".to_string())?;
104 let n = n.ok_or_else(|| "response_geometry='grassmann' requires n".to_string())?;
105 if k == 0 || n == 0 || k > n {
106 return Err("response_geometry='grassmann' requires 1 <= k <= n".to_string());
107 }
108 Ok(Self::Grassmann { k, n })
109 }
110 "stiefel" => {
111 let k = k.ok_or_else(|| "response_geometry='stiefel' requires k".to_string())?;
112 let n = n.ok_or_else(|| "response_geometry='stiefel' requires n".to_string())?;
113 if k == 0 || n == 0 || k > n {
114 return Err("response_geometry='stiefel' requires 1 <= k <= n".to_string());
115 }
116 Ok(Self::Stiefel { k, n })
117 }
118 "poincare" => {
119 let dim =
120 dim.ok_or_else(|| "response_geometry='poincare' requires dim".to_string())?;
121 if dim == 0 {
122 return Err("response_geometry='poincare' requires dim >= 1".to_string());
123 }
124 let curvature = curvature
125 .ok_or_else(|| "response_geometry='poincare' requires curvature".to_string())?;
126 if !(curvature.is_finite() && curvature < 0.0) {
127 return Err(
128 "response_geometry='poincare' requires finite curvature < 0".to_string()
129 );
130 }
131 Ok(Self::Poincare { dim, curvature })
132 }
133 "constant_curvature" => {
134 let dim = dim.ok_or_else(|| {
135 "response_geometry='constant_curvature' requires dim".to_string()
136 })?;
137 if dim == 0 {
138 return Err(
139 "response_geometry='constant_curvature' requires dim >= 1".to_string()
140 );
141 }
142 // curvature defaults to 0 (flat) when not supplied — the user can
143 // supply any finite value; the κ-estimand outer loop will optimize it.
144 let kappa = curvature.unwrap_or(0.0);
145 if !kappa.is_finite() {
146 return Err(
147 "response_geometry='constant_curvature' requires finite curvature"
148 .to_string(),
149 );
150 }
151 Ok(Self::ConstantCurvature { dim, kappa })
152 }
153 other => Err(format!(
154 "response_geometry must be one of 'spd', 'grassmann', 'stiefel', 'poincare', \
155 'constant_curvature', 'spherical', or 'simplex'; got {other:?}"
156 )),
157 }
158 }
159
160 /// Parse a user-facing `response_geometry` label, magic-by-default: the head
161 /// is the geometry name, an optional parenthesised `key=value` list carries
162 /// shape parameters, and anything not given is inferred from the ambient
163 /// column count `cols` of the response matrix.
164 ///
165 /// Recognised forms (case-insensitive, whitespace tolerant):
166 /// - `"spd"` — `n = √cols` (must be a perfect square).
167 /// - `"grassmann(k=2)"` or `"grassmann(k=2,n=5)"` — `n` defaults to
168 /// `cols / k`; `k` is required (it cannot be inferred from `n·k`).
169 /// - `"stiefel(k=2)"` / `"stiefel(k=2,n=5)"` — same inference as Grassmann.
170 /// - `"poincare"` or `"poincare(curvature=-0.5)"` — `dim = cols`; curvature
171 /// defaults to `-1.0`.
172 ///
173 /// This is the single mapping from the formula-DSL string to a constructed
174 /// response manifold; the FFI passes the raw label straight through.
175 pub fn parse(label: &str, cols: usize) -> Result<Self, String> {
176 let lowered = label.trim().to_ascii_lowercase();
177 let (head, params) = match lowered.split_once('(') {
178 Some((h, rest)) => {
179 let rest = rest.trim_end();
180 let inner = rest
181 .strip_suffix(')')
182 .ok_or_else(|| format!("response_geometry {label:?}: missing closing ')'"))?;
183 (h.trim().to_string(), parse_kv(inner)?)
184 }
185 None => (lowered.clone(), Vec::new()),
186 };
187 let get_usize = |key: &str| -> Result<Option<usize>, String> {
188 for (k, v) in ¶ms {
189 if k == key {
190 let parsed: usize = v.parse().map_err(|_| {
191 format!("response_geometry {label:?}: {key} must be a non-negative integer")
192 })?;
193 return Ok(Some(parsed));
194 }
195 }
196 Ok(None)
197 };
198 let get_f64 = |key: &str| -> Result<Option<f64>, String> {
199 for (k, v) in ¶ms {
200 if k == key {
201 let parsed: f64 = v.parse().map_err(|_| {
202 format!("response_geometry {label:?}: {key} must be a real number")
203 })?;
204 return Ok(Some(parsed));
205 }
206 }
207 Ok(None)
208 };
209
210 match head.as_str() {
211 "spd" => {
212 let n = match get_usize("n")? {
213 Some(n) => n,
214 None => {
215 let r = (cols as f64).sqrt().round() as usize;
216 if r * r != cols {
217 return Err(format!(
218 "response_geometry='spd': {cols} response columns is not a perfect \
219 square; pass spd(n=...) explicitly"
220 ));
221 }
222 r
223 }
224 };
225 Self::resolve("spd", Some(n), None, None, None)
226 }
227 "grassmann" | "stiefel" => {
228 let k = get_usize("k")?.ok_or_else(|| {
229 format!("response_geometry='{head}' requires k, e.g. {head}(k=2)")
230 })?;
231 let n = match get_usize("n")? {
232 Some(n) => n,
233 None => {
234 if k == 0 || cols % k != 0 {
235 return Err(format!(
236 "response_geometry='{head}': {cols} response columns is not \
237 divisible by k={k}; pass {head}(k=..,n=..) explicitly"
238 ));
239 }
240 cols / k
241 }
242 };
243 Self::resolve(&head, Some(n), Some(k), None, None)
244 }
245 "poincare" => {
246 let dim = get_usize("dim")?.unwrap_or(cols);
247 let curvature = get_f64("curvature")?.unwrap_or(-1.0);
248 Self::resolve("poincare", None, None, Some(dim), Some(curvature))
249 }
250 "constant_curvature" => {
251 let dim = get_usize("dim")?.unwrap_or(cols);
252 // κ defaults to 0 (flat initial point for the REML optimizer).
253 let kappa = get_f64("kappa")?
254 .or_else(|| get_f64("curvature").ok().flatten())
255 .unwrap_or(0.0);
256 Self::resolve("constant_curvature", None, None, Some(dim), Some(kappa))
257 }
258 other => Err(format!(
259 "response_geometry must be one of 'spd', 'grassmann(k=..)', 'stiefel(k=..)', \
260 'poincare', 'constant_curvature', 'spherical', or 'simplex'; got {other:?}"
261 )),
262 }
263 }
264
265 /// Canonical, fully-specified label echoed back to the caller (mirrors the
266 /// way the sphere/simplex dispatch reports its resolved coordinate label).
267 pub fn canonical_label(&self) -> String {
268 match self {
269 Self::Spd { n } => format!("spd(n={n})"),
270 Self::Grassmann { k, n } => format!("grassmann(k={k},n={n})"),
271 Self::Stiefel { k, n } => format!("stiefel(k={k},n={n})"),
272 Self::Poincare { dim, curvature } => {
273 format!("poincare(dim={dim},curvature={curvature})")
274 }
275 Self::ConstantCurvature { dim, kappa } => {
276 format!("constant_curvature(dim={dim},kappa={kappa})")
277 }
278 }
279 }
280
281 /// Ambient (flattened) coordinate count: the column width of the `values`
282 /// matrix and the `base` vector.
283 pub fn ambient_dim(&self) -> usize {
284 match self {
285 Self::Spd { n } => n * n,
286 Self::Grassmann { k, n } | Self::Stiefel { k, n } => n * k,
287 Self::Poincare { dim, .. } | Self::ConstantCurvature { dim, .. } => *dim,
288 }
289 }
290
291 /// Build the underlying [`RiemannianManifold`] for the matrix geometries.
292 /// `None` for Poincaré, whose primitives are free functions parameterised
293 /// by curvature rather than a trait object.
294 fn riemannian(&self) -> Option<Box<dyn RiemannianManifold>> {
295 match self {
296 Self::Spd { n } => Some(Box::new(SpdManifold::new(*n))),
297 Self::Grassmann { k, n } => GrassmannManifold::new(*k, *n)
298 .ok()
299 .map(|m| Box::new(m) as _),
300 Self::Stiefel { k, n } => StiefelManifold::new(*k, *n).ok().map(|m| Box::new(m) as _),
301 Self::ConstantCurvature { dim, kappa } => {
302 Some(Box::new(ConstantCurvature::new(*dim, *kappa)))
303 }
304 Self::Poincare { .. } => None,
305 }
306 }
307
308 /// Per-point logarithm `log_base(value)` in flat ambient coordinates.
309 fn log_point(
310 &self,
311 base: ArrayView1<'_, f64>,
312 value: ArrayView1<'_, f64>,
313 ) -> GeometryResult<Array1<f64>> {
314 match self {
315 Self::Poincare { curvature, .. } => {
316 crate::manifolds::poincare::log_map(base, value, *curvature)
317 }
318 // ConstantCurvature implements RiemannianManifold::log_map directly.
319 Self::ConstantCurvature { .. }
320 | Self::Spd { .. }
321 | Self::Grassmann { .. }
322 | Self::Stiefel { .. } => self
323 .riemannian()
324 .expect("riemannian response manifold")
325 .log_map(base, value),
326 }
327 }
328
329 /// Per-point exponential `exp_base(tangent)` in flat ambient coordinates.
330 fn exp_point(
331 &self,
332 base: ArrayView1<'_, f64>,
333 tangent: ArrayView1<'_, f64>,
334 ) -> GeometryResult<Array1<f64>> {
335 match self {
336 Self::Poincare { curvature, .. } => {
337 crate::manifolds::poincare::exp_map(base, tangent, *curvature)
338 }
339 Self::ConstantCurvature { .. }
340 | Self::Spd { .. }
341 | Self::Grassmann { .. }
342 | Self::Stiefel { .. } => self
343 .riemannian()
344 .expect("riemannian response manifold")
345 .exp_map(base, tangent),
346 }
347 }
348
349 /// Euclidean / Frobenius distance from an arbitrary ambient row to the
350 /// candidate response geometry, in flat ambient coordinates — the extrinsic
351 /// constraint-violation distance behind [`response_projection_residual`].
352 ///
353 /// Unlike [`log_point`](Self::log_point), which is gatekept to *genuine*
354 /// manifold points on both arguments, this accepts off-manifold `value`. The
355 /// distance is computed in closed form per geometry and is **well-defined for
356 /// every input** — there is no rank-deficiency error path, because the
357 /// distance to a set is defined even where the nearest point is not unique:
358 ///
359 /// * `Gr(k, n)` / `St(k, n)` — distance to the orthonormal-frame set,
360 /// `√Σ_i (σ_i − 1)²` with `σ_i = √max(λ_i(YᵀY), 0)` the singular values of
361 /// the `n × k` frame `Y`. Exact for every rank (`σ_i = 0` columns
362 /// contribute `1` each). Grassmann and Stiefel coincide because this module
363 /// represents Grassmann points by frames — it is a *representation*
364 /// distance, not a subspace/principal-angle distance.
365 /// * SPD cone — distance to the *closed* PSD cone,
366 /// `√(‖skew(A)‖_F² + Σ_{λ_i<0} λ_i²)` with `λ_i` the eigenvalues of the
367 /// symmetric part `sym(A)`. This is the infimum distance to the open SPD
368 /// cone; a zero distance means PSD, **not** strictly PD.
369 /// * Poincaré ball — distance to the *manifold* open ball of radius
370 /// `R = 1/√(−c)`: `max(0, ‖x‖ − R)`. (This uses the true radius `R`, not
371 /// the slightly smaller numerical safety radius used when projecting points
372 /// for a fit, so interior points score exactly zero.)
373 /// * `ConstantCurvature` — distance to the chart *domain*: `0` for `κ ≥ 0`
374 /// (chart is all of `ℝ^d`), else `max(0, ‖x‖ − 1/√(−κ))`. The curvature
375 /// lives in the metric, not the domain, so this is a domain-admissibility
376 /// check only and carries little curvature information.
377 fn manifold_residual(&self, value: ArrayView1<'_, f64>) -> GeometryResult<f64> {
378 match self {
379 Self::Poincare { curvature, .. } => ball_domain_residual(value, *curvature),
380 Self::ConstantCurvature { kappa, .. } => {
381 if *kappa >= 0.0 {
382 Ok(0.0)
383 } else {
384 ball_domain_residual(value, *kappa)
385 }
386 }
387 Self::Spd { n } => {
388 let mat = from_flat(value, *n, *n)?;
389 let symm = sym(&mat);
390 let psd = spectral_map_symmetric(&symm, |lam| Ok(lam.max(0.0)))?;
391 // Distance to the closed PSD cone, measured against the original
392 // (skew included) input so the skew-symmetric part is counted.
393 Ok(frobenius_distance(value, flatten(&psd).view()))
394 }
395 Self::Grassmann { k, n } | Self::Stiefel { k, n } => {
396 use gam_linalg::faer_ndarray::fast_atb;
397 let frame = from_flat(value, *n, *k)?;
398 let gram = fast_atb(&frame, &frame);
399 let (evals, _) = jacobi_symmetric(&gram)?;
400 let mut sq = 0.0_f64;
401 for &lam in evals.iter() {
402 let sigma = lam.max(0.0).sqrt();
403 let d = sigma - 1.0;
404 sq += d * d;
405 }
406 Ok(sq.sqrt())
407 }
408 }
409 }
410
411 /// Squared metric norm `‖v‖²_base` of a tangent at `base`. Used by the
412 /// Karcher iteration's stationarity test. Poincaré uses the conformal
413 /// factor squared; the matrix manifolds and ConstantCurvature use the trait
414 /// metric tensor.
415 fn sq_metric_norm(
416 &self,
417 base: ArrayView1<'_, f64>,
418 v: ArrayView1<'_, f64>,
419 ) -> GeometryResult<f64> {
420 match self {
421 Self::Poincare { curvature, .. } => {
422 let lam = crate::manifolds::poincare::conformal_factor(base, *curvature)?;
423 Ok(lam * lam * v.iter().map(|x| x * x).sum::<f64>())
424 }
425 Self::ConstantCurvature { .. }
426 | Self::Spd { .. }
427 | Self::Grassmann { .. }
428 | Self::Stiefel { .. } => {
429 let g = self
430 .riemannian()
431 .expect("riemannian response manifold")
432 .metric_tensor(base)?;
433 let gv = g.dot(&v);
434 Ok(v.dot(&gv).max(0.0))
435 }
436 }
437 }
438}
439
440/// Batched response-geometry logarithm: map every manifold-valued response row
441/// to its tangent coordinate at `base`. `values` is `(n_rows, ambient)`, `base`
442/// is `(ambient,)`, and the returned tangent is `(n_rows, ambient)` (the same
443/// flat ambient layout — the tangent of a matrix manifold is itself a flattened
444/// matrix). The scalar Gaussian GAMs the caller fits operate column-wise on
445/// this matrix exactly as they do for the sphere.
446pub fn response_log_map(
447 manifold: ResponseManifold,
448 values: ArrayView2<'_, f64>,
449 base: ArrayView1<'_, f64>,
450) -> Result<Array2<f64>, String> {
451 let ambient = manifold.ambient_dim();
452 let (n_rows, cols) = values.dim();
453 if base.len() != ambient {
454 return Err(format!(
455 "response geometry base point has length {}; expected {ambient}",
456 base.len()
457 ));
458 }
459 if cols != ambient {
460 return Err(format!(
461 "response geometry values have {cols} columns; expected {ambient}"
462 ));
463 }
464 let mut out = Array2::<f64>::zeros((n_rows, ambient));
465 for row in 0..n_rows {
466 let tangent = manifold
467 .log_point(base, values.row(row))
468 .map_err(|e| format!("response geometry log map (row {row}): {e}"))?;
469 out.row_mut(row).assign(&tangent);
470 }
471 Ok(out)
472}
473
474/// Batched response-geometry exponential: map predicted tangent coordinates
475/// back to manifold-valued responses at `base`. Inverse of [`response_log_map`]
476/// with the same shapes.
477pub fn response_exp_map(
478 manifold: ResponseManifold,
479 tangent: ArrayView2<'_, f64>,
480 base: ArrayView1<'_, f64>,
481) -> Result<Array2<f64>, String> {
482 let ambient = manifold.ambient_dim();
483 let (n_rows, cols) = tangent.dim();
484 if base.len() != ambient {
485 return Err(format!(
486 "response geometry base point has length {}; expected {ambient}",
487 base.len()
488 ));
489 }
490 if cols != ambient {
491 return Err(format!(
492 "response geometry tangent has {cols} columns; expected {ambient}"
493 ));
494 }
495 if !tangent.iter().all(|v| v.is_finite()) {
496 return Err("response geometry tangent must contain only finite values".to_string());
497 }
498 let mut out = Array2::<f64>::zeros((n_rows, ambient));
499 for row in 0..n_rows {
500 let value = manifold
501 .exp_point(base, tangent.row(row))
502 .map_err(|e| format!("response geometry exp map (row {row}): {e}"))?;
503 out.row_mut(row).assign(&value);
504 }
505 Ok(out)
506}
507
508/// Numerically-stable Euclidean norm `‖v‖₂`, scaled by the largest-magnitude
509/// entry so the squared sum cannot overflow for large but finite inputs.
510fn scaled_l2_norm(v: ArrayView1<'_, f64>) -> f64 {
511 let mut scale = 0.0_f64;
512 for &x in v.iter() {
513 let a = x.abs();
514 if a > scale {
515 scale = a;
516 }
517 }
518 if scale == 0.0 {
519 return 0.0;
520 }
521 let mut ssq = 0.0_f64;
522 for &x in v.iter() {
523 let t = x / scale;
524 ssq += t * t;
525 }
526 scale * ssq.sqrt()
527}
528
529/// Numerically-stable Frobenius distance `‖a − b‖₂` over equal-length flat
530/// vectors, scaled by the largest entrywise difference to avoid overflow.
531fn frobenius_distance(a: ArrayView1<'_, f64>, b: ArrayView1<'_, f64>) -> f64 {
532 let mut scale = 0.0_f64;
533 for (x, y) in a.iter().zip(b.iter()) {
534 let d = (x - y).abs();
535 if d > scale {
536 scale = d;
537 }
538 }
539 if scale == 0.0 {
540 return 0.0;
541 }
542 let mut ssq = 0.0_f64;
543 for (x, y) in a.iter().zip(b.iter()) {
544 let t = (x - y) / scale;
545 ssq += t * t;
546 }
547 scale * ssq.sqrt()
548}
549
550/// Distance from `value` to the open ball of radius `R = 1/√(−c)` (`c < 0`):
551/// `max(0, ‖value‖ − R)`, the true Euclidean infimum distance to the ball.
552/// Errors if the curvature is not a finite negative number.
553fn ball_domain_residual(value: ArrayView1<'_, f64>, curvature: f64) -> GeometryResult<f64> {
554 if !curvature.is_finite() || curvature >= 0.0 {
555 return Err(GeometryError::InvalidPoint(
556 "ball distance requires a finite negative curvature",
557 ));
558 }
559 let radius = (-curvature).sqrt().recip();
560 Ok((scaled_l2_norm(value) - radius).max(0.0))
561}
562
563/// Per-row extrinsic distance from ambient observations to a *candidate*
564/// response geometry — a coordinate-dependent constraint / closure-distance
565/// diagnostic.
566///
567/// What this is (and is not)
568/// -------------------------
569/// This is a cheap, pre-fit **constraint-violation** measure: given a candidate
570/// response geometry, how far does each raw row sit from that geometry's
571/// extrinsic representation (the unit-norm frame, the PSD cone, the Poincaré
572/// ball)? It is **not** the post-fit on/off-manifold membership signal (which
573/// comes from a fitted geometric smooth's residual and posterior predictive
574/// density), and it is **not** a universal cross-geometry model-selection score:
575/// it measures extrinsic constraint violation *in a chosen coordinate chart*,
576/// not intrinsic topology or curvature. Different candidate geometries have
577/// different chart codimensions (a full-dimensional Poincaré/`κ ≥ 0` chart can
578/// score zero trivially), so residuals are not directly comparable across
579/// candidates without a noise model and per-candidate calibration. Use it as a
580/// fast per-candidate gate, with candidate-specific thresholds.
581///
582/// What it computes
583/// ----------------
584/// For each ambient row `x`, [`manifold_residual`](Self::manifold_residual)
585/// returns the closed-form distance to the candidate geometry (well-defined for
586/// every input and every rank — see that method for the per-geometry formulas),
587/// and this returns:
588///
589/// * `residual[i]` — the absolute distance-to-geometry (zero for genuinely
590/// admissible rows; for the matrix manifolds, exact to machine precision).
591/// * `relative[i] = residual[i] / (‖x‖ + eps)` — the distance normalised by the
592/// row's ambient magnitude. **Note:** this is dimensionless but *not*
593/// scale-invariant for the fixed-radius geometries (Stiefel/Grassmann/ball)
594/// and is *not* bounded by `1` (it diverges as `‖x‖ → 0`); it is scale-free
595/// only for the homogeneous SPD cone. Treat it as `input_norm_relative`, not
596/// an off-manifold fraction.
597///
598/// Unlike [`response_log_map`], **no base point is needed**. `values` is
599/// `(n_rows, ambient)`; both returned arrays are `(n_rows,)`. Every fittable
600/// response geometry — including `ConstantCurvature` — has a closed-form
601/// distance, so no variant errors on a valid, finite input.
602pub fn response_projection_residual(
603 manifold: ResponseManifold,
604 values: ArrayView2<'_, f64>,
605) -> Result<(Array1<f64>, Array1<f64>), String> {
606 let ambient = manifold.ambient_dim();
607 let (n_rows, cols) = values.dim();
608 if cols != ambient {
609 return Err(format!(
610 "response geometry values have {cols} columns; expected {ambient}"
611 ));
612 }
613 if !values.iter().all(|v| v.is_finite()) {
614 return Err("response geometry values must contain only finite values".to_string());
615 }
616
617 let mut residual = Array1::<f64>::zeros(n_rows);
618 let mut relative = Array1::<f64>::zeros(n_rows);
619 for row in 0..n_rows {
620 let value = values.row(row);
621 let dist = manifold
622 .manifold_residual(value)
623 .map_err(|e| format!("response geometry residual (row {row}): {e}"))?;
624 let rel = dist / (scaled_l2_norm(value) + GEOMETRY_EPS);
625 if !dist.is_finite() || !rel.is_finite() {
626 return Err(format!(
627 "response geometry residual (row {row}) is non-finite"
628 ));
629 }
630 residual[row] = dist;
631 relative[row] = rel;
632 }
633 Ok((residual, relative))
634}
635
636/// String-driven response-geometry log map: parse the user `label` (with shape
637/// inference from the response column count), pick the base point (intrinsic
638/// Fréchet mean when `base` is `None`), map every row to its tangent, and report
639/// the canonical resolved label. This is the curved-manifold analogue of the
640/// sphere/simplex dispatch and the single entry the FFI calls for these
641/// geometries.
642pub fn dispatch_log_map(
643 values: ArrayView2<'_, f64>,
644 label: &str,
645 base: Option<ArrayView1<'_, f64>>,
646) -> Result<(Array2<f64>, Array1<f64>, String), String> {
647 let manifold = ResponseManifold::parse(label, values.ncols())?;
648 let base_point = match base {
649 Some(b) => b.to_owned(),
650 None => response_frechet_mean(manifold, values, None, 1.0e-12, 256)?,
651 };
652 let tangent = response_log_map(manifold, values, base_point.view())?;
653 Ok((tangent, base_point, manifold.canonical_label()))
654}
655
656/// String-driven response-geometry exponential map: inverse of
657/// [`dispatch_log_map`] given an explicit base point.
658pub fn dispatch_exp_map(
659 tangent: ArrayView2<'_, f64>,
660 label: &str,
661 base: ArrayView1<'_, f64>,
662) -> Result<Array2<f64>, String> {
663 let manifold = ResponseManifold::parse(label, tangent.ncols())?;
664 response_exp_map(manifold, tangent, base)
665}
666
667/// Intrinsic (Karcher) Fréchet mean of manifold-valued responses, the default
668/// base point when the user supplies none. `values` is `(n_rows, ambient)`.
669///
670/// This is the SPD safeguarded Karcher iteration generalised over an arbitrary
671/// [`ResponseManifold`]: a Riemannian gradient-descent on the weighted
672/// dispersion `V(P) = Σ_i w_i ‖log_P(X_i)‖²_P` with the descent direction
673/// `ξ = Σ_i w_i log_P(X_i)` (`= −½ grad V`), a unit Karcher step `exp_P(t·ξ)`
674/// with Armijo backtracking plus a round-off cushion, a best-iterate stall
675/// guard, and the metric-norm stationarity test `‖ξ‖_P ≤ tol`. The SPD-specific
676/// version in [`crate::manifolds::spd::spd_frechet_mean`] remains for the affine
677/// inverse it caches per step; this generic form pays a metric-tensor solve but
678/// covers all four geometries uniformly.
679pub fn response_frechet_mean(
680 manifold: ResponseManifold,
681 values: ArrayView2<'_, f64>,
682 weights: Option<ArrayView1<'_, f64>>,
683 tol: f64,
684 max_iter: usize,
685) -> Result<Array1<f64>, String> {
686 let ambient = manifold.ambient_dim();
687 let (m, cols) = values.dim();
688 if m == 0 || cols != ambient {
689 return Err(format!(
690 "response geometry Fréchet mean: values must be M×{ambient} with M >= 1"
691 ));
692 }
693 if !(tol.is_finite() && tol > 0.0) {
694 return Err("response geometry Fréchet mean tolerance must be finite and positive".into());
695 }
696 let w = crate::normalize_weights(m, weights)
697 .map_err(|_| "response geometry Fréchet mean: invalid weights".to_string())?;
698 let samples: Vec<Array1<f64>> = (0..m).map(|i| values.row(i).to_owned()).collect();
699
700 let dispersion = |p: ArrayView1<'_, f64>| -> Result<f64, String> {
701 let mut acc = 0.0_f64;
702 for (i, x) in samples.iter().enumerate() {
703 let lg = manifold
704 .log_point(p, x.view())
705 .map_err(|e| format!("response geometry Fréchet mean log map: {e}"))?;
706 let sq = manifold
707 .sq_metric_norm(p, lg.view())
708 .map_err(|e| format!("response geometry Fréchet mean metric: {e}"))?;
709 acc += w[i] * sq;
710 }
711 Ok(acc)
712 };
713
714 // Seed the Karcher iteration at a sample whose tangent star is fully
715 // defined, then take one Riemannian averaging step for an interior start.
716 //
717 // A fixed seed at `samples[0]` is fragile: if any *other* sample lies at
718 // that seed's cut locus the seeding log is undefined and the whole mean
719 // aborts, even though the Fréchet mean itself is well defined. On
720 // `Gr(1,n) = ℝP^{n-1}` two orthogonal lines (principal angle π/2) are
721 // exactly such a cut-locus pair, so a design whose first response happens
722 // to be orthogonal to another could never be averaged. Instead, try each
723 // sample as the seed and keep the first whose log-tangents to *every*
724 // sample land: the safeguarded descent below converges to the same mean
725 // from any admissible seed, so this only changes which interior point the
726 // iteration starts from — and the very first sample is chosen whenever it
727 // is admissible (so SPD/Stiefel/Poincaré data with no cut-locus pair seed
728 // exactly as before). A design where every sample sits at another's cut
729 // locus has a genuinely ambiguous mean and is reported as such.
730 let mut seeded: Option<Array1<f64>> = None;
731 let mut last_seed_err = String::new();
732 for seed in &samples {
733 let base = match manifold.exp_point(seed.view(), Array1::<f64>::zeros(ambient).view()) {
734 Ok(base) => base,
735 Err(e) => {
736 last_seed_err = e.to_string();
737 continue;
738 }
739 };
740 let mut xi = Array1::<f64>::zeros(ambient);
741 let mut admissible = true;
742 for (i, x) in samples.iter().enumerate() {
743 match manifold.log_point(base.view(), x.view()) {
744 Ok(lg) => xi.scaled_add(w[i], &lg),
745 Err(e) => {
746 last_seed_err = e.to_string();
747 admissible = false;
748 break;
749 }
750 }
751 }
752 if !admissible {
753 continue;
754 }
755 match manifold.exp_point(base.view(), xi.view()) {
756 Ok(stepped) => {
757 seeded = Some(stepped);
758 break;
759 }
760 Err(e) => {
761 last_seed_err = e.to_string();
762 }
763 }
764 }
765 let mut p = seeded.ok_or_else(|| {
766 format!(
767 "response geometry Fréchet mean init: no admissible seed among samples \
768 (every sample lies at another's cut locus; last error: {last_seed_err})"
769 )
770 })?;
771
772 let mut f_cur = dispersion(p.view())?;
773 let mut best_p = p.clone();
774 let mut best_grad = f64::INFINITY;
775 const STALL_REL: f64 = 5.0e-3;
776 const STALL_PATIENCE: usize = 10;
777 let mut stall = 0_usize;
778 const ARMIJO_C1: f64 = 1.0e-4;
779 const MAX_BACKTRACK_HALVINGS: usize = 60;
780 const ARMIJO_ROUNDOFF_EPS_MULTIPLE: f64 = 8.0;
781
782 for _ in 0..max_iter {
783 let mut xi = Array1::<f64>::zeros(ambient);
784 for (i, x) in samples.iter().enumerate() {
785 let lg = manifold
786 .log_point(p.view(), x.view())
787 .map_err(|e| format!("response geometry Fréchet mean log map: {e}"))?;
788 xi.scaled_add(w[i], &lg);
789 }
790 let grad_norm = manifold
791 .sq_metric_norm(p.view(), xi.view())
792 .map_err(|e| format!("response geometry Fréchet mean metric: {e}"))?
793 .sqrt();
794 if grad_norm <= tol {
795 return Ok(p);
796 }
797
798 let improved = grad_norm < best_grad * (1.0 - STALL_REL);
799 if grad_norm < best_grad {
800 best_grad = grad_norm;
801 best_p.assign(&p);
802 }
803 if improved {
804 stall = 0;
805 } else {
806 stall += 1;
807 if stall >= STALL_PATIENCE {
808 return Ok(best_p);
809 }
810 }
811
812 let pred = grad_norm * grad_norm;
813 let f_tol = ARMIJO_ROUNDOFF_EPS_MULTIPLE * f64::EPSILON * (1.0 + f_cur.abs());
814 let mut t = 1.0_f64;
815 let mut accepted = false;
816 for _ in 0..MAX_BACKTRACK_HALVINGS {
817 let step = &xi * t;
818 let cand = match manifold.exp_point(p.view(), step.view()) {
819 Ok(c) => c,
820 Err(_) => {
821 // The step left the manifold's domain (e.g. a Poincaré
822 // overshoot past the ball boundary); shrink and retry.
823 t *= 0.5;
824 continue;
825 }
826 };
827 let f_cand = match dispersion(cand.view()) {
828 Ok(f) => f,
829 Err(_) => {
830 t *= 0.5;
831 continue;
832 }
833 };
834 if f_cand <= f_cur - 2.0 * ARMIJO_C1 * t * pred + f_tol {
835 p = cand;
836 f_cur = f_cand;
837 accepted = true;
838 break;
839 }
840 t *= 0.5;
841 }
842 if !accepted {
843 return Ok(best_p);
844 }
845 }
846 Err("response geometry Fréchet mean did not reach stationarity within max_iter".into())
847}
848
849// ── Curvature as an estimand on the response geometry (#944 stage 4 / #1104) ──
850//
851// `response_geometry="constant_curvature(dim=d)"` does NOT take a fixed κ from
852// the user: κ is ESTIMATED from the manifold-valued responses. At each κ the
853// family `ConstantCurvature{dim, κ}` is laid down and κ is scored by the HONEST
854// change-of-variables likelihood of the observed chart coordinates `yᵢ` w.r.t.
855// ambient Lebesgue measure `dy` — the density that is automatically normalised on
856// the SAME measure in which the data are observed, regardless of how the manifold
857// is parameterised. This is the crux of the #1104 fix.
858//
859// ## Why dispersion alone (and the self-normalising wrapped Gaussian) is degenerate
860//
861// The generative model is the wrapped normal `yᵢ = exp_μ(vᵢ)`, `vᵢ` isotropic at
862// geodesic scale σ. Its density w.r.t. the Riemannian volume `dvol_κ` is
863// `N(sᵢ;0,σ²)/Jᵧ_κ(sᵢ)` with `sᵢ = d_κ(μ,yᵢ)` the geodesic radius and
864// `J_κ(s) = (sn_κ(s)/s)^{d−1}` the exp-map volume Jacobian
865// (`ConstantCurvature::jacobian_radial`). The naive criterion
866// `½nd·ln(Σsᵢ²/nd)` (dispersion only), and even the full `dvol_κ`-density NLL
867// `Σ[sᵢ²/2σ² + (d/2)ln2πσ² + ln J_κ(sᵢ)]`, are SCALE-DEGENERATE: rescaling the
868// manifold radius `R = 1/√|κ|` rescales every `sᵢ` and every volume element, and
869// the σ-profile absorbs the change with no κ information left. That is exactly
870// why a `dvol_κ`-normalised (self-normalising) wrapped Gaussian rails, and why an
871// intrinsic-volume partition function double-counts: the density is already
872// normalised on `dvol_κ`, so re-integrating its volume adds nothing identifying.
873//
874// ## The restoring force is the ambient (chart) volume element at the DATA points
875//
876// Curvature is identified only when the abstract manifold is tied to the CONCRETE
877// observed chart coordinates `yᵢ`. The data are observed as points of `ℝ^d` under
878// Lebesgue `dy`, so the likelihood must be the density w.r.t. `dy`, obtained from
879// the `dvol_κ`-density by the chart volume factor `dvol_κ/dy = λ_{yᵢ}^d`,
880// `λ_y = 2/(1+κ‖y‖²)`:
881//
882// ```text
883// −ℓ(κ,μ,σ²) = Σᵢ[ sᵢ²/(2σ²) + (d/2)ln(2πσ²) + ln J_κ(sᵢ) − d·ln λ_{yᵢ} ].
884// ```
885//
886// The new term `−d·Σ ln λ_{yᵢ} = d·Σ ln((1+κ‖yᵢ‖²)/2)` is evaluated at every DATA
887// point (not at the mean), so `‖yᵢ‖² > 0` even for mean-centred clouds and it
888// supplies a genuine κ-restoring force: it grows like `+d·κ·Σ‖yᵢ‖²` for small κ
889// and `→ +∞` as κ→+∞ (each `−ln λ_{yᵢ}→+∞`), exactly opposing the dispersion /
890// `ln J_κ` terms which fall as the sphere shrinks. The minimum is therefore
891// INTERIOR at the data-generating curvature. None of `ln J_κ` or `λ` depend on σ,
892// so σ profiles in closed form `σ̂² = D/(nd)`, `D = Σ sᵢ²`.
893//
894// ## Reparameterisation invariance / unit-covariance of κ̂
895//
896// κ carries units of `1/length²`. Under a global rescaling `yᵢ ↦ α·yᵢ` the chart
897// of `M_κ` at scale `α` equals the chart of `M_{κ/α²}` at scale 1 (because
898// `λ` and every geodesic primitive depend on `y` only through `κ‖y‖²`). The whole
899// criterion `V(κ, αy)` therefore equals `V(α²κ, y)`, so its minimiser transforms
900// as `κ̂(αy) = κ̂(y)/α²` — the CORRECT covariance of a curvature with units
901// `1/length²`. The base point μ is held at the κ-independent flat centroid (NOT
902// re-solved per κ): re-solving the Fréchet mean per κ is precisely what
903// re-entangles κ with the chart scale and biases the estimate, so it is removed.
904//
905// `V_p` is a negative log-evidence (lower is better) so κ̂ = argmin V_p; it is the
906// full NLL summed over all `n·d` scalar observations, so `2[V_p(0) − V_p(κ̂)]` is
907// the Wilks LR statistic with a calibrated χ²₁ flatness reference — exactly the
908// contract `profile_ci_walk` / `flatness_lr_test` in `curvature_estimand.rs`
909// consume, with no new outer machinery.
910
911/// Outcome of fitting curvature as an estimand on a constant-curvature response
912/// geometry: the optimised κ̂, its tangent base point, the profile-likelihood CI,
913/// and the interior-point flatness (Wilks) test of κ = 0.
914#[derive(Clone, Debug)]
915pub struct ResponseCurvatureFit {
916 /// The dimension `d` of the constant-curvature response manifold.
917 pub dim: usize,
918 /// The REML/evidence-optimal curvature κ̂ (argmin of the profiled criterion).
919 ///
920 /// **Units `1/length²`** — κ̂ is therefore *scale-dependent*: rescaling the
921 /// cloud `y ↦ α·y` rescales `κ̂ ↦ κ̂/α²`. For a scale-free statement of how
922 /// curved the cloud is, read [`kappa_r2`](Self::kappa_r2) instead. When the
923 /// cloud is curved BEYOND what its spread can resolve (it fills a large
924 /// fraction of the sphere `S^d(1/√κ̂)`), the optimiser rails to the
925 /// chart-resolution cap and [`railed_at_resolution_limit`](Self::railed_at_resolution_limit)
926 /// is `true`: κ̂ is then a *lower bound on |κ|*, not a point estimate.
927 pub kappa_hat: f64,
928 /// The DIMENSIONLESS geometric invariant the cloud actually determines:
929 /// `κ̂ · r²` with `r` = [`characteristic_radius`](Self::characteristic_radius).
930 /// This is scale-FREE (`κ̂·r²` is invariant under `y ↦ α·y`, since `κ̂ ↦ κ̂/α²`
931 /// and `r ↦ α·r`) — the honest answer to "how curved is this cloud relative
932 /// to its own spread". `|κ̂·r²| ≪ 1` ⇒ nearly flat at this scale; `κ̂·r² ↗ (π/2)²`
933 /// ⇒ the cloud fills the sphere and curvature is at the chart-resolution limit.
934 pub kappa_r2: f64,
935 /// Characteristic geodesic radius `r` of the cloud at κ = 0 (the doubled-gauge
936 /// chart distance `r = 2·max_i‖y_i − μ‖`): the length scale against which κ̂ is
937 /// dimensionless. Reported so the caller can convert between scale-dependent κ̂
938 /// and the scale-free `κ̂·r²` without re-deriving the chart gauge.
939 pub characteristic_radius: f64,
940 /// The intrinsic Fréchet-mean base point at κ̂ (the tangent expansion point
941 /// the scalar GAMs are fitted around).
942 pub base: Array1<f64>,
943 /// Profiled criterion value `V_p(κ̂)` (concentrated negative log-evidence).
944 pub v_p_hat: f64,
945 /// `true` when the κ̂ search converged ONTO the chart-resolution cap rather
946 /// than an interior optimum: the data want curvature at or beyond the
947 /// conjugate radius of their geodesic spread (the cloud fills the sphere).
948 /// In that case κ̂ / the CI upper end are NOT a resolved point estimate but a
949 /// HONEST "curvature exceeds chart-resolvable range at this scale" flag — the
950 /// caller must report it as such and never as a silent `κ̂ = ci_hi`. The
951 /// hyperbolic side cannot rail this way (κ < 0 has no conjugate radius), so a
952 /// rail here always means strongly spherical relative to the spread.
953 pub railed_at_resolution_limit: bool,
954 /// `true` only when the SIGN of κ̂ is statistically resolved — i.e. the
955 /// profile-likelihood CI excludes 0 (`profile_ci.verdict ≠ Flat`).
956 ///
957 /// ## Why a point estimate alone is not enough (the #944/#1059 flat-floor)
958 ///
959 /// Curvature is resolvable only through the dimensionless product `κ·r²`
960 /// (see [`kappa_r2`](Self::kappa_r2)); the per-point Fisher information for κ
961 /// scales like `σ⁴`. When the cloud is nearly flat at its own scale
962 /// (`|κ·r²| ≪ 1`), the profiled criterion is so shallow that its single-cloud
963 /// argmin κ̂ can land on the WRONG SIDE OF ZERO purely by Monte-Carlo
964 /// fluctuation — empirically a coin-flip below `|κ·r²| ≈ 0.03`, reliable above
965 /// `≈ 0.09` (the #944 power curve). The estimand itself is UNBIASED (the
966 /// criterion averaged over clouds minimises exactly at κ⋆), so this is a
967 /// resolution limit, not a bias.
968 ///
969 /// The CI, in contrast, is honest in this regime: at an under-resolved
970 /// operating point it reports `Flat` (straddles 0) rather than a confident
971 /// wrong sign — it essentially never claims the wrong-signed geometry. So the
972 /// SIGN-bearing summary the caller may quote is the CI verdict, not the bare
973 /// κ̂. This flag exposes that contract on the point-estimate surface: when it
974 /// is `false`, κ̂'s sign is noise — the caller must report "curvature not
975 /// resolved at this scale (|κ·r²| too small)" and quote the CI / `kappa_r2`,
976 /// never a sign-confident κ̂. It is the flat-floor twin of
977 /// [`railed_at_resolution_limit`](Self::railed_at_resolution_limit) (the
978 /// spherical-cap rail); together they bracket the two ends of the resolvable
979 /// `κ·r²` band where κ̂ is a genuine interior point estimate.
980 pub sign_resolved: bool,
981 /// Profile-likelihood CI for κ and the geometry verdict from its sign.
982 pub profile_ci: crate::curvature_estimand::KappaProfileCi,
983 /// Interior-point χ²₁ likelihood-ratio test of flatness (κ = 0).
984 pub flatness: crate::curvature_estimand::FlatnessTest,
985}
986
987/// Chart-validity bounds on κ for a constant-curvature response geometry built
988/// from the supplied responses, plus the characteristic geodesic radius
989/// `ρ_max = 2·max_i‖y_i − μ‖` against which κ is made dimensionless.
990///
991/// Returns `(kappa_min, kappa_max, rho_max)`.
992///
993/// * **Lower (hyperbolic) bound.** The κ-stereographic chart requires
994/// `1 + κ‖x‖² > 0` at every point measured from the chart origin, i.e.
995/// `κ > −1/R²` with `R² = max_i ‖y_i‖²`. With a safety margin: `−0.999/R²`.
996/// * **Upper (spherical) bound.** Unlike the hyperbolic side this is NOT
997/// unbounded: on a sphere of curvature κ the geodesic radius cannot exceed the
998/// conjugate radius `π/√κ`, beyond which the exp-map volume Jacobian
999/// `J_κ = (sn_κ/·)^{d−1}` changes sign (clamped to 0 here) and `ln J_κ` would
1000/// collapse `V_p` toward `−∞`, railing the optimiser onto a spurious shell.
1001/// The κ = 0 geodesic radius of the farthest point from the centroid is
1002/// `ρ_max = 2·max_i‖y_i − μ‖` (doubled-gauge chart). We cap κ so that radius
1003/// stays strictly inside the first conjugate shell with a 10% margin:
1004/// `√κ·ρ_max ≤ 0.9π ⇒ κ_max = (0.9π / ρ_max)²`. This keeps every geodesic
1005/// radius before the antipodal singularity along the whole search/CI walk.
1006///
1007/// `κ_max` is the chart-RESOLUTION limit of the cloud: at it the geodesic spread
1008/// fills `(0.9π)² ≈ (π/2·1.8)²` of the conjugate shell, i.e. the cloud nearly
1009/// fills the sphere `S^d(1/√κ_max)`. The DIMENSIONLESS product `κ_max·ρ_max²
1010/// = (0.9π)²` is fixed and data-scale-free — it is the natural "the cloud is
1011/// maximally curved relative to its spread" sentinel the rail check compares κ̂ to.
1012fn response_kappa_bounds(values: ArrayView2<'_, f64>) -> (f64, f64, f64) {
1013 let (n_rows, dim) = values.dim();
1014 // ‖y_i‖² from the chart origin (governs the λ / hyperbolic-chart constraint).
1015 let mut r2_max = 0.0_f64;
1016 for row in values.outer_iter() {
1017 let r2 = row.dot(&row);
1018 if r2 > r2_max {
1019 r2_max = r2;
1020 }
1021 }
1022 // ‖y_i − μ‖² from the centroid (governs the spherical conjugate-radius cap).
1023 let mut centroid = Array1::<f64>::zeros(dim.max(1));
1024 if n_rows > 0 && dim > 0 {
1025 for row in values.outer_iter() {
1026 centroid += &row;
1027 }
1028 centroid.mapv_inplace(|v| v / n_rows as f64);
1029 }
1030 let mut s2_max = 0.0_f64;
1031 if dim > 0 {
1032 for row in values.outer_iter() {
1033 let diff = &row - ¢roid;
1034 let r2 = diff.dot(&diff);
1035 if r2 > s2_max {
1036 s2_max = r2;
1037 }
1038 }
1039 }
1040 if r2_max <= 0.0 && s2_max <= 0.0 {
1041 // Degenerate (all points at the origin): κ is unidentified; use a wide
1042 // symmetric default so the optimiser/CI report a flat, unbounded result.
1043 return (-1.0e6, 1.0e6, 0.0);
1044 }
1045 // Keep a safety margin off the singular hyperbolic boundary.
1046 let kappa_min = if r2_max > 0.0 {
1047 -0.999 / r2_max
1048 } else {
1049 -1.0e6
1050 };
1051 // Conjugate-radius cap: ρ_max = 2·max‖y_i − μ‖ is the κ=0 geodesic radius.
1052 let rho_max = 2.0 * s2_max.sqrt();
1053 let kappa_max = if s2_max > 0.0 {
1054 let edge = 0.9 * std::f64::consts::PI / rho_max;
1055 edge * edge
1056 } else {
1057 1.0e6
1058 };
1059 (kappa_min, kappa_max, rho_max)
1060}
1061
1062/// Profiled curvature criterion `V_p(κ)` for the constant-curvature response
1063/// geometry: the σ-profiled HONEST change-of-variables negative log-likelihood of
1064/// the observed chart coordinates `y_i` at curvature `κ`, expressed w.r.t. ambient
1065/// Lebesgue measure `dy`. Lower is better (κ̂ = argmin). Returns `(V_p, base)`;
1066/// the base point is the κ-INDEPENDENT flat centroid (the tangent expansion point
1067/// that the scalar GAMs are fitted around), held fixed across κ so the estimate is
1068/// not re-entangled with the chart scale.
1069///
1070/// The model is the wrapped normal `y_i = exp_{μ,κ}(v_i)` with isotropic geodesic
1071/// scale σ; `s_i = d_κ(μ, y_i)` is the geodesic radius and `J_κ(s)` the exp-map
1072/// volume Jacobian. The density on the Riemannian volume `dvol_κ` is
1073/// `N(s_i;0,σ²)/J_κ(s_i)`; converting to ambient `dy` multiplies by the chart
1074/// volume factor `λ_{y_i}^d`, `λ_y = 2/(1+κ‖y‖²)`. The negative log-likelihood is
1075///
1076/// ```text
1077/// −ℓ(κ,σ²) = Σ_i[ s_i²/(2σ²) + (d/2)ln(2πσ²) + ln J_κ(s_i) − d·ln λ_{y_i} ].
1078/// ```
1079///
1080/// `ln J_κ` and `λ` do not depend on σ, so σ profiles in closed form
1081/// `σ̂² = D/(nd)`, `D = Σ s_i²`. The `−d·Σ ln λ_{y_i}` term — evaluated at the DATA
1082/// points, not the mean — is the κ-restoring force that breaks the scale
1083/// degeneracy of the dispersion / `dvol_κ`-density alone (see the module notes).
1084/// Additive constants independent of κ are kept implicit; they cancel in every
1085/// LR / profile-drop the CI machinery forms. μ is the closed-form flat centroid,
1086/// so the criterion is a pure function of κ with no inner tolerance/iteration
1087/// budget (the outer κ̂ search owns those).
1088pub fn response_curvature_criterion(
1089 values: ArrayView2<'_, f64>,
1090 dim: usize,
1091 kappa: f64,
1092) -> Result<(f64, Array1<f64>), String> {
1093 if !kappa.is_finite() {
1094 return Err("response curvature criterion: kappa must be finite".into());
1095 }
1096 let (n_rows, cols) = values.dim();
1097 if n_rows == 0 || cols != dim || dim == 0 {
1098 return Err(format!(
1099 "response curvature criterion: values must be N×{dim} with N >= 1"
1100 ));
1101 }
1102 // κ-independent base point: the flat (ambient) centroid. Holding μ fixed across
1103 // κ is the de-entangling move — re-solving the Fréchet mean per κ couples the
1104 // base to the chart scale and biases κ̂ (#1104 root cause).
1105 let mut base = Array1::<f64>::zeros(dim);
1106 for row in values.outer_iter() {
1107 base += &row;
1108 }
1109 base.mapv_inplace(|v| v / n_rows as f64);
1110
1111 let chart = ConstantCurvature::new(dim, kappa);
1112 // Reject κ at/over the chart boundary (1 + κ‖x‖² ≤ 0) at the centroid or any
1113 // data point: the geodesic primitives are undefined there. The bracket in
1114 // `response_kappa_bounds` keeps the optimiser strictly inside, but a CI/LR
1115 // probe can still land on the edge, so guard rather than panic.
1116 chart
1117 .conformal_factor(base.view())
1118 .map_err(|e| format!("response curvature criterion: base off chart: {e}"))?;
1119
1120 let d = dim as f64;
1121 let mut dispersion = 0.0_f64; // D = Σ s_i²
1122 let mut ln_jac = 0.0_f64; // Σ ln J_κ(s_i)
1123 let mut ln_lambda = 0.0_f64; // Σ ln λ_{y_i}
1124 // Geodesic radii s_i = d_κ(μ, y_i) for every row, computed in a single
1125 // batched pass (four rows per SIMD lane-group). `distance_batch` is
1126 // bit-for-bit identical to the per-row `distance`, so D, Σ ln J, and Σ ln λ
1127 // below are unchanged; it also validates each y_i is in-chart.
1128 let mut radii = vec![0.0_f64; n_rows];
1129 chart
1130 .distance_batch(base.view(), values, &mut radii)
1131 .map_err(|e| format!("response curvature criterion distance: {e}"))?;
1132 for (row, &s) in values.outer_iter().zip(radii.iter()) {
1133 dispersion += s * s;
1134 // ln J_κ(s_i): exp-map volume Jacobian (≥ 0); floor before the log so the
1135 // conjugate-shell clamp (J → 0 on the κ>0 antipodal shell) is a large
1136 // finite penalty rather than −∞.
1137 ln_jac += chart.jacobian_radial(s).max(1.0e-300).ln();
1138 // ln λ_{y_i} = ln(2) − ln(1 + κ‖y_i‖²); `conformal_factor` validates chart.
1139 let lam = chart
1140 .conformal_factor(row)
1141 .map_err(|e| format!("response curvature criterion conformal factor: {e}"))?;
1142 ln_lambda += lam.ln();
1143 }
1144 let nobs = (n_rows * dim) as f64;
1145 // Floor the dispersion so a (near-)perfect flat fit does not blow ln up; the
1146 // floor is far below any genuine residual scale and cancels in profile drops.
1147 let disp = dispersion.max(1.0e-300 * nobs.max(1.0));
1148
1149 // σ profiles in closed form: σ̂² = D/(nd). Substituting and dropping the
1150 // κ-independent constant (nd/2)(1 + ln 2π):
1151 // V_p(κ) = (nd/2)·ln(D/(nd)) + Σ ln J_κ(s_i) − d·Σ ln λ_{y_i}.
1152 let v_p = 0.5 * nobs * (disp / nobs).ln() + ln_jac - d * ln_lambda;
1153 Ok((v_p, base))
1154}
1155
1156/// Fit curvature as an estimand on a constant-curvature response geometry.
1157///
1158/// κ̂ is the minimiser of the profiled criterion [`response_curvature_criterion`]
1159/// (the σ-profiled honest change-of-variables negative log-evidence of the wrapped
1160/// normal w.r.t. ambient measure), found by a golden-section search inside the
1161/// chart-validity bracket. The base point μ is the κ-independent flat centroid, so
1162/// every `V_p` evaluation scores the SAME geometry without re-entangling κ with the
1163/// chart scale (the #1104 fix). The exact outer
1164/// curvature `V_p''(κ̂)` is taken by a central second difference of the same
1165/// criterion and handed to [`profile_ci_walk`](crate::profile_ci_walk)
1166/// to size the initial Wald step; the CI itself is the exact χ²₁ profile crossing.
1167/// Flatness is the interior-point χ²₁ LR test
1168/// [`flatness_lr_test`](crate::flatness_lr_test). κ = 0 is an interior
1169/// point of the analytic `S^d ← ℝ^d → H^d` family, so no boundary correction is
1170/// applied. Returns the κ̂, its tangent base point, the profile CI, and the Wilks
1171/// flatness test for the fit summary.
1172///
1173/// ## Scale-awareness and honest railing (#1104)
1174///
1175/// κ has units `1/length²`, so a cloud of characteristic geodesic radius `r`
1176/// resolves only the DIMENSIONLESS product `κ·r²` (every chart primitive depends
1177/// on `y` through `κ‖y‖²`, hence `V(κ, αy) = V(α²κ, y)` and `κ̂ ↦ κ̂/α²` under
1178/// `y ↦ αy`). The fit therefore also returns:
1179/// * `kappa_r2 = κ̂·r²` — the scale-FREE invariant the cloud actually determines
1180/// (how curved relative to its own spread), and `characteristic_radius = r`;
1181/// * `railed_at_resolution_limit` — `true` when the data want curvature at or
1182/// beyond the conjugate radius of their spread (the cloud fills the sphere),
1183/// so the search converges onto the spherical cap. There κ̂ is a LOWER BOUND on
1184/// `|κ|`, not a resolved point estimate, and the caller must report "curvature
1185/// exceeds chart-resolvable range at this scale" rather than silently quoting
1186/// `κ̂ = ci_hi`. This is the #1104 fix: a tightly-concentrated near-spherical
1187/// cloud (e.g. unit-normalised OLMo activations) no longer SILENTLY rails to a
1188/// huge scale-dependent `ci_hi` while claiming a point estimate + CI.
1189pub fn fit_response_curvature(
1190 values: ArrayView2<'_, f64>,
1191 dim: usize,
1192 level: f64,
1193 tol: f64,
1194 max_iter: usize,
1195) -> Result<ResponseCurvatureFit, String> {
1196 if dim == 0 {
1197 return Err("constant-curvature response geometry requires dim >= 1".into());
1198 }
1199 let (n_rows, cols) = values.dim();
1200 if n_rows == 0 || cols != dim {
1201 return Err(format!(
1202 "constant-curvature response geometry: values must be N×{dim} with N >= 1"
1203 ));
1204 }
1205 if !(level > 0.0 && level < 1.0) {
1206 return Err("response curvature CI level must lie in (0, 1)".into());
1207 }
1208 let (kappa_min, kappa_max, rho_max) = response_kappa_bounds(values);
1209
1210 // `V_p` as a closure over the criterion; threaded through both the κ̂ search
1211 // and the CI walk. Every evaluation uses the same κ-independent flat-centroid
1212 // base, so the criterion is a clean 1-D function of κ.
1213 let mut v_p = |kappa: f64| -> Result<f64, String> {
1214 response_curvature_criterion(values, dim, kappa).map(|(v, _)| v)
1215 };
1216
1217 // ── κ̂: golden-section minimisation inside the chart bracket. ────────────
1218 // The dispersion criterion is smooth and unimodal in practice; golden
1219 // section is derivative-free and respects the bracket bounds exactly.
1220 const GOLDEN_INV: f64 = 0.618_033_988_749_894_8; // 1/φ
1221 let mut a = kappa_min;
1222 let mut b = kappa_max;
1223 let mut c = b - GOLDEN_INV * (b - a);
1224 let mut d_pt = a + GOLDEN_INV * (b - a);
1225 let mut fc = v_p(c)?;
1226 let mut fd = v_p(d_pt)?;
1227 let ktol = (tol * (kappa_max - kappa_min)).max(tol).max(1.0e-12);
1228 for _ in 0..max_iter {
1229 if (b - a).abs() <= ktol {
1230 break;
1231 }
1232 if fc < fd {
1233 b = d_pt;
1234 d_pt = c;
1235 fd = fc;
1236 c = b - GOLDEN_INV * (b - a);
1237 fc = v_p(c)?;
1238 } else {
1239 a = c;
1240 c = d_pt;
1241 fc = fd;
1242 d_pt = a + GOLDEN_INV * (b - a);
1243 fd = v_p(d_pt)?;
1244 }
1245 }
1246 let kappa_hat = 0.5 * (a + b);
1247 let (v_p_hat, base) = response_curvature_criterion(values, dim, kappa_hat)?;
1248
1249 // ── Honest chart-resolution-rail detection. ─────────────────────────────
1250 // The spherical cap κ_max is the curvature at which the cloud's geodesic
1251 // spread ρ_max fills `(0.9π)²` of the conjugate shell — i.e. the cloud nearly
1252 // fills the sphere S^d(1/√κ_max). When the criterion's optimum sits AT that
1253 // cap (the data want κ ≥ κ_max, but the chart cannot resolve a sphere smaller
1254 // than the cloud), the search converges onto the upper bracket and κ̂ ≈ κ_max
1255 // is NOT a resolved point estimate — it is a lower bound on |κ|. We flag this
1256 // so the caller reports "curvature exceeds chart-resolvable range at this
1257 // scale" instead of silently quoting κ̂ / ci_hi as if interior. The detection
1258 // is scale-free: it triggers when κ̂ lands within the final golden-section
1259 // resolution of κ_max (the dimensionless product κ̂·ρ_max² ↗ (0.9π)²), never
1260 // by an absolute κ threshold. The hyperbolic side has no conjugate radius, so
1261 // only the spherical (upper) cap can rail this way.
1262 let span = kappa_max - kappa_min;
1263 let rail_margin = (0.02 * span).max(ktol);
1264 let railed_at_resolution_limit = kappa_hat >= kappa_max - rail_margin;
1265
1266 // Dimensionless scale-free invariant κ̂·r²: the geometric content the cloud
1267 // actually determines (invariant under y ↦ αy). r = ρ_max is the κ=0 doubled-
1268 // gauge characteristic radius; for a degenerate (point) cloud r = 0 and the
1269 // product is 0 (κ unidentified). This is what the caller should report as the
1270 // honest "how curved relative to its spread" number alongside the dimensional κ̂.
1271 let kappa_r2 = kappa_hat * rho_max * rho_max;
1272
1273 // Exact outer curvature V_p''(κ̂) by a central second difference, on a step
1274 // scaled to the bracket; only used to size the Wald bracket of the CI walk.
1275 let h = (1.0e-3 * (kappa_max - kappa_min)).max(1.0e-6);
1276 let v_pp = if (kappa_hat - h) > kappa_min && (kappa_hat + h) < kappa_max {
1277 let vp = v_p(kappa_hat + h)?;
1278 let vm = v_p(kappa_hat - h)?;
1279 (vp - 2.0 * v_p_hat + vm) / (h * h)
1280 } else {
1281 // Near a bound: leave it to the walk's default step.
1282 f64::NAN
1283 };
1284
1285 let profile_ci = crate::curvature_estimand::profile_ci_walk(
1286 &mut v_p, kappa_hat, v_pp, kappa_min, kappa_max, level, ktol,
1287 )?;
1288 let flatness = crate::curvature_estimand::flatness_lr_test(&mut v_p, kappa_hat)?;
1289
1290 // The sign of κ̂ is statistically resolved iff the profile CI excludes 0 — the
1291 // CI is the honest sign-bearing summary (it reports Flat under-resolution rather
1292 // than a confident wrong sign), so we mirror its verdict onto the point-estimate
1293 // surface. Below the resolvable `κ·r²` floor (`|κ·r²| ≪ 1`) the bare κ̂ argmin can
1294 // flip sign on Monte-Carlo noise, so `false` here means "do not quote κ̂'s sign".
1295 let sign_resolved = !matches!(
1296 profile_ci.verdict,
1297 crate::curvature_estimand::CurvatureVerdict::Flat
1298 );
1299
1300 Ok(ResponseCurvatureFit {
1301 dim,
1302 kappa_hat,
1303 kappa_r2,
1304 characteristic_radius: rho_max,
1305 railed_at_resolution_limit,
1306 sign_resolved,
1307 base,
1308 v_p_hat,
1309 profile_ci,
1310 flatness,
1311 })
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317 use ndarray::{Array2, array};
1318
1319 fn round_trip(manifold: ResponseManifold, values: Array2<f64>) {
1320 let base =
1321 response_frechet_mean(manifold, values.view(), None, 1e-12, 500).expect("frechet mean");
1322 let tangent = response_log_map(manifold, values.view(), base.view()).expect("log map");
1323 let back = response_exp_map(manifold, tangent.view(), base.view()).expect("exp map");
1324 for row in 0..values.nrows() {
1325 for col in 0..values.ncols() {
1326 assert!(
1327 (back[[row, col]] - values[[row, col]]).abs() < 1e-6,
1328 "{manifold:?} exp∘log mismatch at ({row},{col}): {} vs {}",
1329 back[[row, col]],
1330 values[[row, col]]
1331 );
1332 }
1333 }
1334 }
1335
1336 #[test]
1337 fn spd_round_trip_and_mean() {
1338 // Three 2×2 SPD matrices, row-major flat.
1339 let values = array![
1340 [2.0, 0.0, 0.0, 1.0],
1341 [1.0, 0.3, 0.3, 2.0],
1342 [3.0, -0.5, -0.5, 1.5],
1343 ];
1344 round_trip(ResponseManifold::Spd { n: 2 }, values);
1345 }
1346
1347 #[test]
1348 fn grassmann_round_trip_and_mean() {
1349 // Gr(1, 3): unit columns (lines through the origin), n·k = 3 flat.
1350 let values = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.6, 0.8, 0.0],];
1351 round_trip(ResponseManifold::Grassmann { k: 1, n: 3 }, values);
1352 }
1353
1354 #[test]
1355 fn stiefel_round_trip_and_mean() {
1356 // St(1, 3): unit 1-frames in ℝ³ (== sphere S²).
1357 let values = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.6, 0.8],];
1358 round_trip(ResponseManifold::Stiefel { k: 1, n: 3 }, values);
1359 }
1360
1361 #[test]
1362 fn stiefel_k2_round_trip_and_mean_n_lt_2k() {
1363 // St(3, 2): three orthonormal 2-frames in ℝ³ clustered near [e0, e1],
1364 // exercising the genuine canonical-metric logarithm (k ≥ 2) through the
1365 // full Karcher-mean → log → exp round trip. This is the n < 2k regime
1366 // (n = 3 < 2k = 4) where the economical 2k-block form is rank-deficient.
1367 // Before the k ≥ 2 Stiefel logarithm existed this aborted in
1368 // Fréchet-mean init with a misleading cut-locus error (#1637).
1369 let (c2, s2) = (0.2_f64.cos(), 0.2_f64.sin());
1370 let (c1, s1) = (0.15_f64.cos(), 0.15_f64.sin());
1371 let values = array![
1372 [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
1373 [c2, 0.0, 0.0, 1.0, s2, 0.0],
1374 [1.0, 0.0, 0.0, c1, 0.0, s1],
1375 ];
1376 round_trip(ResponseManifold::Stiefel { k: 2, n: 3 }, values);
1377 }
1378
1379 #[test]
1380 fn stiefel_k2_round_trip_and_mean_n_ge_2k() {
1381 // St(4, 2): the n ≥ 2k regime (n = 4 = 2k), clustered 2-frames in ℝ⁴.
1382 let (c0, s0) = (0.1_f64.cos(), 0.1_f64.sin());
1383 let (c1, s1) = (0.12_f64.cos(), 0.12_f64.sin());
1384 let values = array![
1385 [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
1386 [c0, 0.0, 0.0, 1.0, s0, 0.0, 0.0, 0.0],
1387 [1.0, 0.0, 0.0, c1, 0.0, 0.0, 0.0, s1],
1388 ];
1389 round_trip(ResponseManifold::Stiefel { k: 2, n: 4 }, values);
1390 }
1391
1392 #[test]
1393 fn poincare_round_trip_and_mean() {
1394 let values = array![[0.1, 0.2], [-0.3, 0.1], [0.2, -0.25],];
1395 round_trip(
1396 ResponseManifold::Poincare {
1397 dim: 2,
1398 curvature: -1.0,
1399 },
1400 values,
1401 );
1402 }
1403
1404 #[test]
1405 fn resolver_rejects_bad_shapes() {
1406 assert!(ResponseManifold::resolve("grassmann", Some(2), Some(3), None, None).is_err());
1407 assert!(ResponseManifold::resolve("spd", None, None, None, None).is_err());
1408 assert!(ResponseManifold::resolve("poincare", None, None, Some(2), Some(1.0)).is_err());
1409 assert!(ResponseManifold::resolve("nonsense", None, None, None, None).is_err());
1410 assert_eq!(
1411 ResponseManifold::resolve("spd", Some(3), None, None, None).unwrap(),
1412 ResponseManifold::Spd { n: 3 }
1413 );
1414 }
1415
1416 #[test]
1417 fn parse_infers_shapes_from_columns() {
1418 // SPD: n from the perfect-square column count.
1419 assert_eq!(
1420 ResponseManifold::parse("spd", 9).unwrap(),
1421 ResponseManifold::Spd { n: 3 }
1422 );
1423 assert!(ResponseManifold::parse("spd", 8).is_err());
1424 // Grassmann/Stiefel: n inferred as cols / k.
1425 assert_eq!(
1426 ResponseManifold::parse("grassmann(k=2)", 10).unwrap(),
1427 ResponseManifold::Grassmann { k: 2, n: 5 }
1428 );
1429 assert_eq!(
1430 ResponseManifold::parse("Stiefel( k = 2 , n = 4 )", 8).unwrap(),
1431 ResponseManifold::Stiefel { k: 2, n: 4 }
1432 );
1433 assert!(ResponseManifold::parse("grassmann", 10).is_err());
1434 assert!(ResponseManifold::parse("grassmann(k=3)", 10).is_err());
1435 // Poincaré: dim = cols, default curvature -1.
1436 assert_eq!(
1437 ResponseManifold::parse("poincare", 3).unwrap(),
1438 ResponseManifold::Poincare {
1439 dim: 3,
1440 curvature: -1.0
1441 }
1442 );
1443 assert_eq!(
1444 ResponseManifold::parse("poincare(curvature=-0.5)", 3).unwrap(),
1445 ResponseManifold::Poincare {
1446 dim: 3,
1447 curvature: -0.5
1448 }
1449 );
1450 assert!(ResponseManifold::parse("hyperbolic", 3).is_err());
1451 }
1452
1453 #[test]
1454 fn dispatch_round_trips_through_user_label() {
1455 // Drive the full string-selected user path for each geometry: parse the
1456 // label, build the intrinsic base, log to the tangent, exp back.
1457 let cases: Vec<(&str, Array2<f64>)> = vec![
1458 (
1459 "spd",
1460 array![
1461 [2.0, 0.0, 0.0, 1.0],
1462 [1.0, 0.3, 0.3, 2.0],
1463 [3.0, -0.5, -0.5, 1.5],
1464 ],
1465 ),
1466 (
1467 "grassmann(k=1)",
1468 array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.6, 0.8, 0.0]],
1469 ),
1470 (
1471 "stiefel(k=1)",
1472 array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.6, 0.8]],
1473 ),
1474 ("poincare", array![[0.1, 0.2], [-0.3, 0.1], [0.2, -0.25]]),
1475 ];
1476 for (label, values) in cases {
1477 let (tangent, base, canonical) =
1478 dispatch_log_map(values.view(), label, None).expect("dispatch log");
1479 assert!(canonical.starts_with(label.split('(').next().unwrap()));
1480 let back = dispatch_exp_map(tangent.view(), label, base.view()).expect("dispatch exp");
1481 for row in 0..values.nrows() {
1482 for col in 0..values.ncols() {
1483 assert!(
1484 (back[[row, col]] - values[[row, col]]).abs() < 1e-6,
1485 "{label} exp∘log mismatch at ({row},{col}): {} vs {}",
1486 back[[row, col]],
1487 values[[row, col]]
1488 );
1489 }
1490 }
1491 }
1492 }
1493
1494 #[test]
1495 fn ambient_dim_matches_layout() {
1496 assert_eq!(ResponseManifold::Spd { n: 3 }.ambient_dim(), 9);
1497 assert_eq!(ResponseManifold::Grassmann { k: 2, n: 5 }.ambient_dim(), 10);
1498 assert_eq!(ResponseManifold::Stiefel { k: 2, n: 4 }.ambient_dim(), 8);
1499 assert_eq!(
1500 ResponseManifold::Poincare {
1501 dim: 4,
1502 curvature: -1.0
1503 }
1504 .ambient_dim(),
1505 4
1506 );
1507 }
1508
1509 /// Deterministic xorshift64* + Box–Muller standard normals — a dependency-free
1510 /// reproducible source for the synthetic known-κ clouds. Seeded per call so
1511 /// the test is bit-stable across runs and platforms.
1512 struct DetNormal {
1513 state: u64,
1514 spare: Option<f64>,
1515 }
1516 impl DetNormal {
1517 fn new(seed: u64) -> Self {
1518 Self {
1519 state: seed | 1,
1520 spare: None,
1521 }
1522 }
1523 fn u01(&mut self) -> f64 {
1524 // xorshift64*; take the top 53 bits as a (0,1) double.
1525 let mut x = self.state;
1526 x ^= x >> 12;
1527 x ^= x << 25;
1528 x ^= x >> 27;
1529 self.state = x;
1530 let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
1531 ((v >> 11) as f64 + 0.5) / (1u64 << 53) as f64
1532 }
1533 fn normal(&mut self) -> f64 {
1534 if let Some(z) = self.spare.take() {
1535 return z;
1536 }
1537 // Box–Muller; clamp u1 away from 0 so ln is finite.
1538 let u1 = self.u01().max(1e-12);
1539 let u2 = self.u01();
1540 let r = (-2.0 * u1.ln()).sqrt();
1541 let theta = 2.0 * std::f64::consts::PI * u2;
1542 self.spare = Some(r * theta.sin());
1543 r * theta.cos()
1544 }
1545 }
1546
1547 /// Build a synthetic cloud at known curvature `k_star`: `n` points whose
1548 /// geodesic normal coordinates about `center` are i.i.d. isotropic Gaussian
1549 /// of scale `sigma`, exp-mapped onto `M_{k_star}`, then mean-centred in the
1550 /// ambient chart to mimic the real (mean-subtracted) response clouds.
1551 fn synth_cloud(dim: usize, k_star: f64, n: usize, sigma: f64, seed: u64) -> Array2<f64> {
1552 let manifold = ResponseManifold::ConstantCurvature { dim, kappa: k_star };
1553 let center = Array1::<f64>::zeros(dim);
1554 let mut rng = DetNormal::new(seed);
1555 let mut values = Array2::<f64>::zeros((n, dim));
1556 for i in 0..n {
1557 let t: Array1<f64> = (0..dim).map(|_| sigma * rng.normal()).collect();
1558 let y = manifold
1559 .exp_point(center.view(), t.view())
1560 .expect("exp tangent to response");
1561 values.row_mut(i).assign(&y);
1562 }
1563 // Mean-centre in the ambient chart (the real-data preprocessing).
1564 let mut mean = Array1::<f64>::zeros(dim);
1565 for row in values.outer_iter() {
1566 mean += &row;
1567 }
1568 mean.mapv_inplace(|v| v / n as f64);
1569 for mut row in values.outer_iter_mut() {
1570 row -= &mean;
1571 }
1572 values
1573 }
1574
1575 /// The #1104 reparameterisation-invariant curvature estimator: on synthetic
1576 /// clouds generated at known κ⋆ the fitted κ̂ must be (a) INTERIOR to the
1577 /// chart bracket (never railed), (b) close to κ⋆ and MONOTONE in κ⋆, (c)
1578 /// produce a smooth (non-degenerate) χ²₁ flatness p-value that does not reject
1579 /// the flat truth, and (d) be correctly COVARIANT under a global rescaling of
1580 /// the cloud (κ has units 1/length², so `y ↦ α y ⇒ κ̂ ↦ κ̂/α²`).
1581 #[test]
1582 fn fit_response_curvature_is_reparameterization_invariant() {
1583 let dim = 3usize;
1584 // Unit-ish scale: σ=0.15 keeps every geodesic radius (≈ a few·σ) well
1585 // inside the κ-stereographic chart for the most hyperbolic κ⋆ = −1.5
1586 // (chart needs ‖y‖² < 1/1.5 ≈ 0.667).
1587 let sigma = 0.15;
1588 let n = 300usize;
1589 let k_stars = [-1.5_f64, -0.5, 0.0, 0.6, 1.2];
1590 let mut k_hats = Vec::new();
1591 for (idx, &k_star) in k_stars.iter().enumerate() {
1592 let values = synth_cloud(dim, k_star, n, sigma, 0xC0FFEE ^ (idx as u64 + 1));
1593 let (kmin, kmax, _rho) = response_kappa_bounds(values.view());
1594 let fit = fit_response_curvature(values.view(), dim, 0.95, 1e-12, 256)
1595 .expect("response curvature fit");
1596 k_hats.push(fit.kappa_hat);
1597
1598 // (a) INTERIOR: κ̂ strictly inside the bracket, not railed to either end.
1599 let span = kmax - kmin;
1600 assert!(
1601 fit.kappa_hat > kmin + 0.02 * span && fit.kappa_hat < kmax - 0.02 * span,
1602 "κ⋆={k_star}: κ̂={} railed to bracket [{kmin}, {kmax}]",
1603 fit.kappa_hat
1604 );
1605
1606 // (b-direct) recovery within a sane tolerance (finite-sample bias is
1607 // O(1/n); the estimator only needs the right region and sign).
1608 assert!(
1609 (fit.kappa_hat - k_star).abs() <= 0.6 + 0.3 * k_star.abs(),
1610 "κ⋆={k_star}: κ̂={} too far",
1611 fit.kappa_hat
1612 );
1613
1614 // (c) the profile CI is a valid interval bracketing κ̂.
1615 assert!(
1616 fit.profile_ci.ci_lo <= fit.kappa_hat && fit.kappa_hat <= fit.profile_ci.ci_hi,
1617 "κ⋆={k_star}: CI [{}, {}] excludes κ̂={}",
1618 fit.profile_ci.ci_lo,
1619 fit.profile_ci.ci_hi,
1620 fit.kappa_hat
1621 );
1622 // The flatness LR statistic and p-value are valid; the p-value is a
1623 // genuine probability strictly between 0 and 1 (smooth, not 0/1).
1624 assert!(fit.flatness.lr_stat >= 0.0);
1625 assert!(
1626 fit.flatness.p_value > 0.0 && fit.flatness.p_value < 1.0,
1627 "κ⋆={k_star}: degenerate flatness p={}",
1628 fit.flatness.p_value
1629 );
1630 // The flat truth κ⋆ = 0 must NOT be rejected at 5% (lr < χ²_{1,.95}).
1631 if k_star == 0.0 {
1632 assert!(
1633 fit.flatness.lr_stat < 3.84,
1634 "flat truth wrongly rejected: lr={}",
1635 fit.flatness.lr_stat
1636 );
1637 }
1638
1639 // (d) RESCALING COVARIANCE: scale the SAME cloud by α and refit; κ̂
1640 // must transform as κ̂/α² (curvature has units 1/length²). We reuse the
1641 // identical points so the only change is the global scale.
1642 let alpha = 1.5_f64;
1643 let scaled = values.mapv(|v| alpha * v);
1644 let fit_scaled = fit_response_curvature(scaled.view(), dim, 0.95, 1e-12, 256)
1645 .expect("scaled response curvature fit");
1646 let expected = fit.kappa_hat / (alpha * alpha);
1647 // Tolerance scales with magnitude; the transform is exact in the
1648 // criterion (V(κ, αy) = V(α²κ, y)) up to the finite golden-section /
1649 // bracket discretisation.
1650 assert!(
1651 (fit_scaled.kappa_hat - expected).abs() <= 0.05 + 0.05 * expected.abs(),
1652 "κ⋆={k_star}: rescale covariance broken: κ̂(αy)={} vs κ̂(y)/α²={}",
1653 fit_scaled.kappa_hat,
1654 expected
1655 );
1656 }
1657
1658 // (b-monotone) κ̂ is monotone increasing in κ⋆ across the whole sweep.
1659 for w in k_hats.windows(2) {
1660 assert!(w[1] > w[0] - 0.05, "κ̂ not monotone in κ⋆: {:?}", k_hats);
1661 }
1662 }
1663
1664 /// d = 1 carries REDUCED curvature information: the transverse volume
1665 /// Jacobian is identically 1 (radial isometry), so κ is identified by the
1666 /// conformal-factor restoring force `−d·Σ ln λ_{y_i}` alone (#944 power
1667 /// analysis). The estimator must still run end-to-end, return an INTERIOR
1668 /// κ̂, and produce a valid CI — never divide/exponentiate the absent
1669 /// transverse direction.
1670 #[test]
1671 fn fit_response_curvature_d1_uses_conformal_term_only() {
1672 let sigma = 0.12;
1673 let n = 400usize;
1674 for &k_star in &[-1.0_f64, 0.0, 0.8] {
1675 let values = synth_cloud(1, k_star, n, sigma, 0xD1 ^ (k_star.to_bits()));
1676 let (kmin, kmax, _rho) = response_kappa_bounds(values.view());
1677 let fit = fit_response_curvature(values.view(), 1, 0.95, 1e-12, 256)
1678 .expect("d=1 curvature fit");
1679 let span = kmax - kmin;
1680 assert!(
1681 fit.kappa_hat > kmin + 0.01 * span && fit.kappa_hat < kmax - 0.01 * span,
1682 "d=1 κ⋆={k_star}: κ̂={} railed to [{kmin},{kmax}]",
1683 fit.kappa_hat
1684 );
1685 assert!(
1686 fit.profile_ci.ci_lo <= fit.kappa_hat && fit.kappa_hat <= fit.profile_ci.ci_hi,
1687 "d=1 κ⋆={k_star}: CI excludes κ̂"
1688 );
1689 assert!(fit.kappa_hat.is_finite() && fit.v_p_hat.is_finite());
1690 }
1691 }
1692
1693 /// The criterion guard must reject κ probes AT or PAST the chart boundary
1694 /// gracefully (an `Err`, never a panic / NaN): on the hyperbolic edge
1695 /// `1 + κ‖y‖² ≤ 0` and on the spherical antipode. The `response_kappa_bounds`
1696 /// bracket stays strictly interior, but a stray CI/LR probe can land on the
1697 /// edge, so the criterion itself must be defensive.
1698 #[test]
1699 fn response_curvature_criterion_rejects_boundary_probes() {
1700 // A cloud with a known max radius R²; the hyperbolic edge is κ = −1/R².
1701 let values = array![[0.5_f64, 0.0], [-0.4, 0.3], [0.1, -0.5]];
1702 let r2_max = values
1703 .outer_iter()
1704 .map(|r| r.dot(&r))
1705 .fold(0.0_f64, f64::max);
1706 // Exactly on / past the hyperbolic edge: 1 + κ‖y‖² = 0 (or < 0).
1707 let kappa_edge = -1.0 / r2_max;
1708 assert!(
1709 response_curvature_criterion(values.view(), 2, kappa_edge).is_err(),
1710 "criterion must reject the hyperbolic chart edge κ=−1/R²"
1711 );
1712 assert!(
1713 response_curvature_criterion(values.view(), 2, 1.5 * kappa_edge).is_err(),
1714 "criterion must reject past the hyperbolic chart edge"
1715 );
1716 // Interior κ just inside the edge succeeds and is finite.
1717 let (v, _) = response_curvature_criterion(values.view(), 2, 0.9 * kappa_edge)
1718 .expect("interior κ valid");
1719 assert!(v.is_finite());
1720 // Non-finite κ is rejected up front.
1721 assert!(response_curvature_criterion(values.view(), 2, f64::NAN).is_err());
1722 assert!(response_curvature_criterion(values.view(), 2, f64::INFINITY).is_err());
1723 }
1724
1725 // ── Projection residual (distance to candidate manifold) ───────────────
1726
1727 #[test]
1728 fn projection_residual_is_zero_for_on_manifold_points() {
1729 // On-manifold rows are their own nearest point, so the residual is ~0
1730 // row-wise. No base point / Fréchet mean is involved — projection is
1731 // base-independent — so this no longer depends on the inputs forming an
1732 // admissible Karcher seed.
1733 let cases: Vec<(ResponseManifold, Array2<f64>)> = vec![
1734 (
1735 ResponseManifold::Spd { n: 2 }, // PD: eigenvalues {2,1} and {2,1}
1736 array![[2.0, 0.0, 0.0, 1.0], [1.5, 0.5, 0.5, 1.5]],
1737 ),
1738 (
1739 ResponseManifold::Grassmann { k: 1, n: 3 }, // unit columns
1740 array![[1.0, 0.0, 0.0], [0.6, 0.8, 0.0]],
1741 ),
1742 (
1743 ResponseManifold::Poincare {
1744 dim: 2,
1745 curvature: -1.0,
1746 }, // strictly inside the ball
1747 array![[0.1, 0.2], [-0.3, 0.1]],
1748 ),
1749 ];
1750 for (manifold, values) in cases {
1751 let (resid, rel) =
1752 response_projection_residual(manifold, values.view()).expect("projection residual");
1753 for row in 0..values.nrows() {
1754 assert!(
1755 resid[row] < 1e-9,
1756 "{manifold:?} on-manifold row {row} should have ~0 residual, got {}",
1757 resid[row]
1758 );
1759 assert!(rel[row] < 1e-9 && rel[row] >= 0.0);
1760 }
1761 }
1762 }
1763
1764 #[test]
1765 fn projection_residual_recovers_known_off_manifold_displacement() {
1766 // Closed-form checks against the exact nearest-point distance.
1767
1768 // Gr(1,3) / sphere: nearest unit vector to x is x/‖x‖, so the distance
1769 // is |‖x‖ − 1|. [2,0,0] ⇒ 1; [0,3,0] ⇒ 2. Relative = dist/‖x‖.
1770 let g = ResponseManifold::Grassmann { k: 1, n: 3 };
1771 let gv = array![[2.0, 0.0, 0.0], [0.0, 3.0, 0.0]];
1772 let (gres, grel) = response_projection_residual(g, gv.view()).expect("grassmann");
1773 assert!((gres[0] - 1.0).abs() < 1e-12, "got {}", gres[0]);
1774 assert!((gres[1] - 2.0).abs() < 1e-12, "got {}", gres[1]);
1775 assert!((grel[0] - 0.5).abs() < 1e-12);
1776 assert!((grel[1] - 2.0 / 3.0).abs() < 1e-12);
1777
1778 // SPD(2): nearest PSD matrix clamps negative eigenvalues to 0, so the
1779 // distance is the norm of the discarded negative part. [[1,0],[0,-1]]
1780 // has eigenvalue −1 discarded ⇒ distance 1; ‖x‖_F = √2.
1781 let s = ResponseManifold::Spd { n: 2 };
1782 let sv = array![[1.0, 0.0, 0.0, -1.0]];
1783 let (sres, srel) = response_projection_residual(s, sv.view()).expect("spd");
1784 assert!((sres[0] - 1.0).abs() < 1e-9, "got {}", sres[0]);
1785 assert!((srel[0] - 1.0 / 2.0_f64.sqrt()).abs() < 1e-9);
1786
1787 // Poincaré ball (c = −1, true radius R = 1): the distance to the open
1788 // ball is max(0, ‖x‖ − R). [3,0] ⇒ exactly 2 (not 3 − (1 − BOUNDARY_EPS)
1789 // — the diagnostic uses the manifold radius, not the safety radius).
1790 let p = ResponseManifold::Poincare {
1791 dim: 2,
1792 curvature: -1.0,
1793 };
1794 let pv = array![[3.0, 0.0]];
1795 let (pres, _prel) = response_projection_residual(p, pv.view()).expect("poincare");
1796 assert!((pres[0] - 2.0).abs() < 1e-12, "got {}", pres[0]);
1797
1798 // A different curvature (c = −4, R = 1/2): [2,0] ⇒ 2 − 0.5 = 1.5.
1799 let p4 = ResponseManifold::Poincare {
1800 dim: 2,
1801 curvature: -4.0,
1802 };
1803 let (p4res, _) =
1804 response_projection_residual(p4, array![[2.0, 0.0]].view()).expect("poincare c=-4");
1805 assert!((p4res[0] - 1.5).abs() < 1e-12, "got {}", p4res[0]);
1806 }
1807
1808 #[test]
1809 fn projection_residual_validates_shapes_and_finiteness() {
1810 let manifold = ResponseManifold::Spd { n: 2 }; // ambient = 4
1811 // Wrong column count.
1812 let bad_cols = array![[1.0, 2.0, 3.0]];
1813 assert!(response_projection_residual(manifold, bad_cols.view()).is_err());
1814 // Non-finite value.
1815 let nan_vals = array![[f64::NAN, 0.0, 0.0, 1.0]];
1816 assert!(response_projection_residual(manifold, nan_vals.view()).is_err());
1817 let inf_vals = array![[f64::INFINITY, 0.0, 0.0, 1.0]];
1818 assert!(response_projection_residual(manifold, inf_vals.view()).is_err());
1819 }
1820
1821 #[test]
1822 fn projection_residual_separates_on_and_off_manifold() {
1823 // The motivating case, now honestly answered: an on-manifold row sits
1824 // at zero distance from the candidate shape; a row pushed off it has a
1825 // clearly positive distance. This is the shape-plausibility signal that
1826 // gates which topology is worth fitting — not the post-fit membership
1827 // decision, which comes from the fitted surface's residual instead.
1828 let manifold = ResponseManifold::Grassmann { k: 1, n: 3 };
1829 let on = array![[0.6, 0.8, 0.0]]; // a genuine unit direction
1830 let off = array![[0.6, 0.8, 1.4]]; // same direction, pushed off-sphere
1831
1832 let (resid_on, _) = response_projection_residual(manifold, on.view()).expect("on");
1833 let (resid_off, _) = response_projection_residual(manifold, off.view()).expect("off");
1834
1835 assert!(
1836 resid_on[0] < 1e-9,
1837 "on-manifold should be ~0, got {}",
1838 resid_on[0]
1839 );
1840 assert!(
1841 resid_off[0] > 1e-2 && resid_off[0] > resid_on[0],
1842 "off-manifold distance ({}) must clearly exceed on-manifold ({})",
1843 resid_off[0],
1844 resid_on[0]
1845 );
1846 }
1847
1848 #[test]
1849 fn projection_residual_supports_k_greater_than_one_frames() {
1850 // k > 1 frames use the closed form √Σ(σ_i − 1)². St(2,3), ambient = 6,
1851 // row-major n×k.
1852 let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
1853
1854 // An orthonormal frame [e1 | e2] is its own nearest point ⇒ residual 0.
1855 let on = array![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
1856 let (resid_on, _) = response_projection_residual(manifold, on.view()).expect("on");
1857 assert!(
1858 resid_on[0] < 1e-9,
1859 "orthonormal frame should be ~0, got {}",
1860 resid_on[0]
1861 );
1862
1863 // Scale the first column by 2: Y = [2·e1 | e2]. YᵀY = diag(4,1) ⇒
1864 // σ = (2,1), distance √((2−1)²+(1−1)²) = 1, relative = 1/‖Y‖_F = 1/√5.
1865 let off = array![[2.0, 0.0, 0.0, 1.0, 0.0, 0.0]];
1866 let (resid_off, rel_off) = response_projection_residual(manifold, off.view()).expect("off");
1867 assert!((resid_off[0] - 1.0).abs() < 1e-9, "got {}", resid_off[0]);
1868 assert!(
1869 (rel_off[0] - 1.0 / 5.0_f64.sqrt()).abs() < 1e-9,
1870 "got {}",
1871 rel_off[0]
1872 );
1873
1874 // Grassmann(2,4) gives the identical score for the same frame data.
1875 let g = ResponseManifold::Grassmann { k: 2, n: 4 };
1876 let g_on = array![[1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]];
1877 let (g_resid, _) = response_projection_residual(g, g_on.view()).expect("grassmann");
1878 assert!(g_resid[0] < 1e-9, "got {}", g_resid[0]);
1879 }
1880
1881 #[test]
1882 fn projection_residual_handles_nontrivial_eigenvectors() {
1883 // A frame whose Gram is NOT diagonal, so the singular values come from a
1884 // genuine eigendecomposition. Y = [[1,1],[0,1],[0,0]] (St(2,3)):
1885 // YᵀY = [[1,1],[1,2]], eigenvalues (3±√5)/2, σ = ((1+√5)/2, (√5−1)/2).
1886 // distance² = (σ₁−1)² + (σ₂−1)².
1887 let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
1888 let y = array![[1.0, 1.0, 0.0, 1.0, 0.0, 0.0]]; // row-major rows [1,1],[0,1],[0,0]
1889 let (resid, _) = response_projection_residual(manifold, y.view()).expect("frame");
1890 let s5 = 5.0_f64.sqrt();
1891 let sig1 = (1.0 + s5) / 2.0;
1892 let sig2 = (s5 - 1.0) / 2.0;
1893 let expect = ((sig1 - 1.0).powi(2) + (sig2 - 1.0).powi(2)).sqrt();
1894 assert!(
1895 (resid[0] - expect).abs() < 1e-9,
1896 "got {} want {}",
1897 resid[0],
1898 expect
1899 );
1900 }
1901
1902 #[test]
1903 fn projection_residual_is_defined_for_rank_deficient_frames() {
1904 // A rank-deficient frame has a well-defined distance even though the
1905 // nearest orthonormal frame is not unique — distance to a compact set is
1906 // always defined, so this must NOT error. Two identical columns e1 give
1907 // YᵀY = [[1,1],[1,1]], σ = (√2, 0), distance √((√2−1)²+(0−1)²) = √(4−2√2).
1908 let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
1909 let degenerate = array![[1.0, 1.0, 0.0, 0.0, 0.0, 0.0]]; // both columns = e1
1910 let (resid, _) =
1911 response_projection_residual(manifold, degenerate.view()).expect("rank-deficient ok");
1912 let expect = (4.0 - 2.0 * 2.0_f64.sqrt()).sqrt(); // ≈ 1.0823922
1913 assert!(
1914 (resid[0] - expect).abs() < 1e-9,
1915 "got {} want {}",
1916 resid[0],
1917 expect
1918 );
1919
1920 // Minimal case: zero vector on the sphere (Gr(1,3)). Every unit vector is
1921 // a nearest point and the distance is exactly 1 — also must not error.
1922 let sphere = ResponseManifold::Grassmann { k: 1, n: 3 };
1923 let (zres, _) =
1924 response_projection_residual(sphere, array![[0.0, 0.0, 0.0]].view()).expect("zero");
1925 assert!((zres[0] - 1.0).abs() < 1e-12, "got {}", zres[0]);
1926 }
1927
1928 #[test]
1929 fn projection_residual_handles_tiny_full_rank_frame() {
1930 // A tiny but full-rank frame must NOT be rejected as rank-deficient: the
1931 // distance is scale-correct. Y = 1e-7·[e1 | e2] (St(2,3)) ⇒ σ = (1e-7,
1932 // 1e-7), distance √2·(1 − 1e-7) ≈ 1.41421342.
1933 let manifold = ResponseManifold::Stiefel { k: 2, n: 3 };
1934 let tiny = array![[1e-7, 0.0, 0.0, 1e-7, 0.0, 0.0]];
1935 let (resid, _) = response_projection_residual(manifold, tiny.view()).expect("tiny ok");
1936 let expect = 2.0_f64.sqrt() * (1.0 - 1e-7);
1937 assert!(
1938 (resid[0] - expect).abs() < 1e-9,
1939 "got {} want {}",
1940 resid[0],
1941 expect
1942 );
1943 }
1944
1945 #[test]
1946 fn projection_residual_spd_nonsymmetric_and_singular() {
1947 // Non-symmetric input: A = [[1,1],[-1,1]] has sym(A) = I (no negative
1948 // part), but the distance to the PSD cone still counts the skew part:
1949 // ‖A − I‖_F = √2.
1950 let spd = ResponseManifold::Spd { n: 2 };
1951 let asym = array![[1.0, 1.0, -1.0, 1.0]]; // row-major [[1,1],[-1,1]]
1952 let (ares, _) = response_projection_residual(spd, asym.view()).expect("nonsym");
1953 assert!((ares[0] - 2.0_f64.sqrt()).abs() < 1e-9, "got {}", ares[0]);
1954
1955 // A singular PSD matrix diag(1,0) is in the closed cone ⇒ distance 0
1956 // (even though it is not strictly positive definite).
1957 let singular = array![[1.0, 0.0, 0.0, 0.0]];
1958 let (sres, _) = response_projection_residual(spd, singular.view()).expect("singular psd");
1959 assert!(
1960 sres[0] < 1e-12,
1961 "singular PSD should be ~0, got {}",
1962 sres[0]
1963 );
1964 }
1965
1966 #[test]
1967 fn projection_residual_poincare_interior_shell_is_zero() {
1968 // A point in the numerical safety shell R_safe < ‖x‖ < R is a genuine
1969 // interior point of the manifold ball, so it must score exactly 0 — the
1970 // diagnostic uses the true radius, not the projection safety radius.
1971 let p = ResponseManifold::Poincare {
1972 dim: 2,
1973 curvature: -1.0,
1974 };
1975 let shell = array![[0.999999, 0.0]]; // inside R = 1, outside R_safe ≈ 0.99999
1976 let (resid, _) = response_projection_residual(p, shell.view()).expect("shell");
1977 assert!(
1978 resid[0] < 1e-12,
1979 "interior point must be 0, got {}",
1980 resid[0]
1981 );
1982 }
1983
1984 #[test]
1985 fn projection_residual_handles_constant_curvature_domain() {
1986 // ConstantCurvature is a fittable response geometry produced by the
1987 // resolver/parser, so it must return a closed-form distance, not error.
1988 // κ ≥ 0: chart is all of ℝ^d ⇒ every finite row scores 0.
1989 let pos = ResponseManifold::parse("constant_curvature(dim=3,kappa=1.0)", 3)
1990 .expect("parse constant_curvature");
1991 assert!(matches!(pos, ResponseManifold::ConstantCurvature { .. }));
1992 let (pres, _) =
1993 response_projection_residual(pos, array![[0.1, 9.0, -100.0]].view()).expect("kappa>=0");
1994 assert!(pres[0] < 1e-12, "κ≥0 finite row must be 0, got {}", pres[0]);
1995
1996 // κ < 0: chart is the ball of radius 1/√(−κ). For κ = −1, R = 1, so a
1997 // point of norm 3 is at distance 2; an interior point is at 0.
1998 let neg = ResponseManifold::ConstantCurvature {
1999 dim: 2,
2000 kappa: -1.0,
2001 };
2002 let (nres, _) = response_projection_residual(neg, array![[3.0, 0.0], [0.2, 0.1]].view())
2003 .expect("kappa<0");
2004 assert!((nres[0] - 2.0).abs() < 1e-12, "got {}", nres[0]);
2005 assert!(nres[1] < 1e-12, "interior row must be 0, got {}", nres[1]);
2006 }
2007
2008 #[test]
2009 fn projection_residual_accepts_empty_batch() {
2010 // A zero-row batch is valid and returns empty arrays for every geometry.
2011 let manifold = ResponseManifold::Spd { n: 2 }; // ambient = 4
2012 let empty = Array2::<f64>::zeros((0, 4));
2013 let (resid, rel) = response_projection_residual(manifold, empty.view()).expect("empty");
2014 assert_eq!(resid.len(), 0);
2015 assert_eq!(rel.len(), 0);
2016 }
2017}