Skip to main content

gam_terms/
latent.rs

1//! `LatentCoord` — per-row latent coordinates as a first-class gamfit parameter.
2//!
3//! The Riemannian update path follows manifold GPLVM practice (mGPLVM;
4//! Jensen/Kao/Tran/Stevenson 2020 and related head-direction / population
5//! manifold work): angular, spherical, and product-topology latents are
6//! updated on their natural manifold instead of as Euclidean coordinates
7//! with basis-side periodic hacks. Retractions and Euclidean-to-Riemannian
8//! Hessian conversion follow Absil/Mahony/Sepulchre (2008) and the Manopt /
9//! Pymanopt implementation pattern. In the audit-revised gauge framing, the
10//! Riemannian update is itself a gauge restriction: Circle/Sphere/Torus
11//! structure identifies the latent up to the corresponding global isometry
12//! (for example one rotation per cycle), not up to the full diffeomorphism
13//! group of an unconstrained Euclidean latent chart.
14//!
15//! ## Summary
16//!
17//! `LatentCoordValues` is the structural sibling of [`SpatialLogKappaCoords`]
18//! (see [`crate::smooth`]). Both store a flat `Array1<f64>` that the
19//! REML/IFT outer loop treats as *design-moving, non-penalty-like*
20//! hyper-coordinates. `SpatialLogKappaCoords` holds one or more kernel-shape
21//! coordinates per spatial term. `LatentCoordValues`
22//! holds an `N × d` matrix of per-row latent coordinates `t_n ∈ ℝ^d`.
23//!
24//! For a Duchon (or any radial) basis:
25//!
26//! ```text
27//! Φ_{n,k} = φ(‖t_n − c_k‖),
28//! ∂Φ_{n,k}/∂t_n = φ'(r_{nk}) · (t_n − c_k) / r_{nk}.
29//! ```
30//!
31//! The radial-gradient `φ'(r)` is the same scalar the kernel-shape machinery already
32//! computes via [`crate::basis::duchon_radial_jets`]; the chain rule
33//! `(t_n − c_k)/r_{nk}` is what differs between "differentiate against the
34//! kernel scale" and "differentiate against the first kernel argument t".
35//! Everything downstream of `HyperDesignDerivative::from_implicit` (matrix-free
36//! Newton, IFT cache, persistent warm-start, REML/LAML evaluation) is reused
37//! verbatim.
38//!
39//! ## Gauge fixing
40//!
41//! The bare data-fit `½‖y − Φ(t)β‖²` is invariant under any diffeomorphism
42//! `t ↦ φ(t)` (absorb into a re-fit β), so the inner Hessian in the latent
43//! block is singular and IFT breaks. [`LatentIdMode`] enumerates the
44//! gauge-fix penalties exposed at the configuration layer:
45//!
46//! * [`LatentIdMode::AuxPrior`] — iVAE-style auxiliary-conditional prior
47//!   `R_id(t,u) = ½ μ ‖t − ĥ(u)‖²` where `ĥ` is a small ridge / linear map
48//!   fit internally against the auxiliary `u`. `μ` is REML-selectable like a
49//!   smoothing parameter only when the marginal likelihood includes the
50//!   log-`μ` normalizer, `ĥ` is at least C¹, and the conditional precision is
51//!   positive-definite on the anchored subspace. Under those regularity
52//!   conditions this is the principled identifiability fix (Khemakhem et al.
53//!   2020).
54//! * [`LatentIdMode::DimSelection`] — ARD on each latent axis. One ridge
55//!   penalty per axis; REML drives unused axes' precision to infinity only
56//!   after `AuxPrior` or a future isometry prior fixes the gauge.
57//! * [`LatentIdMode::None`] — no gauge fix. Useful only as an explicit
58//!   opt-out; the caller is responsible for separately providing a unique
59//!   inner minimum (e.g. via a custom penalty).
60//!
61//! [`LatentIdMode::IsometryToReference`] (proposal §4(b)) anchors the latent to
62//! a caller-supplied reference configuration via `½ μ ‖t − reference‖²` with a
63//! REML-selectable `μ`, fixing the gauge without an auxiliary signal `u`.
64
65use crate::basis::{BasisError, RadialScalarKind};
66use gam_problem::LatentRetractionRegistry;
67use ndarray::{Array1, Array2, Array3, ArrayView1, ArrayView2, ArrayView3};
68use std::sync::atomic::{AtomicU64, Ordering};
69const SPHERE_NORMAL_PIN: f64 = 1.0;
70static NEXT_LATENT_COORD_ID: AtomicU64 = AtomicU64::new(1);
71
72fn next_latent_coord_id() -> u64 {
73    NEXT_LATENT_COORD_ID.fetch_add(1, Ordering::Relaxed)
74}
75
76/// Choice of auxiliary-prior conditional mean estimator `ĥ(u)`.
77///
78/// `Ridge` is the cheap default that closes form (one `K_u × K_u` solve);
79/// `Linear` is equivalent to `Ridge` with zero ridge and is intended for
80/// auxiliaries `u` that are already low-dimensional and well-conditioned.
81#[derive(Debug, Clone, Copy)]
82pub enum AuxPriorFamily {
83    /// Ridge regression `t ≈ U · A` with a small diagonal regularizer.
84    /// The default ridge strength is `1e-6 · trace(UᵀU)/p`, which is
85    /// numerically benign and never under-constrains the fit when
86    /// `n_obs > p`.
87    Ridge,
88    /// Plain linear projection (no ridge). Errors out at construction if
89    /// `UᵀU` is singular.
90    Linear,
91}
92
93/// Strength of the auxiliary-prior identifiability penalty.
94///
95/// `Auto` defers the choice to REML — the strength is added to the outer
96/// vector as one extra `ρ`-axis (one log-precision per `LatentCoord`). When
97/// the caller supplies an explicit `Fixed(μ)` the strength is held constant
98/// throughout the fit; useful for warm-starts and reproducibility. The REML
99/// path is valid only with the prior normalizer included, a C¹ conditional
100/// mean map, and positive-definite precision on the anchored subspace.
101#[derive(Debug, Clone, Copy)]
102pub enum AuxPriorStrength {
103    Auto,
104    Fixed(f64),
105}
106
107/// Identifiability / gauge-fix mode for a [`LatentCoordValues`] block.
108///
109/// `AuxPrior` is currently the only standalone gauge-fixing mode; see the
110/// module docstring. `DimSelection` must be paired with `AuxPrior` (or a
111/// future isometry mode) by higher-level assembly before fitting.
112#[derive(Debug, Clone)]
113pub enum LatentIdMode {
114    /// Conditional Gaussian prior `p(t | u)` with mean `ĥ(u)` fit by
115    /// `family`. The penalty contribution is
116    /// `R_id = ½ μ · ‖t − ĥ(u)‖²`. `u` has shape `(n_obs, p)`. If
117    /// `strength == Auto`, REML selection of `μ` requires the log-`μ`
118    /// normalizer, C¹ regularity of `ĥ`, and positive-definiteness on the
119    /// subspace anchored by `u`.
120    AuxPrior {
121        u: Array2<f64>,
122        family: AuxPriorFamily,
123        strength: AuxPriorStrength,
124    },
125    /// Auxiliary prior plus ARD over latent axes. `AuxPrior` supplies the
126    /// identifiability anchor; `init_log_precision` seeds the per-axis ARD
127    /// coordinates.
128    AuxPriorDimSelection {
129        u: Array2<f64>,
130        family: AuxPriorFamily,
131        strength: AuxPriorStrength,
132        init_log_precision: Option<Array1<f64>>,
133    },
134    /// ARD over latent axes. One ridge penalty per latent axis; the per-axis
135    /// log-precision joins the outer ρ vector. `init_log_precision` seeds
136    /// the per-axis ρ — a vector of length `d`. `None` defaults to a flat
137    /// zero seed (precision = 1 on every axis).
138    DimSelection {
139        init_log_precision: Option<Array1<f64>>,
140    },
141    /// Behaviorally-anchored head (issue #912). The auxiliary signal is
142    /// promoted from a fixed-covariate *prior* to a modeled *outcome*: a GLM
143    /// behavioral head `g(E[y|t]) = a + t·w` whose design columns are the
144    /// latent codes contributes a *likelihood* term to the joint objective,
145    /// so REML balances reconstruction vs. behavioral fit with no trade-off
146    /// scalar (magic by default).
147    ///
148    /// The head's coefficients are direct hyperparameters appended to θ (one
149    /// `(1 + d)` block per η-channel), like the AuxPrior log-`μ`. Because a
150    /// single binary label pins ~1 gauge dimension, `AuxOutcome` *composes*
151    /// with `DimSelection` ARD (the `init_log_precision` seed) and the
152    /// isometry pin rather than replacing them; the validator requires that
153    /// composition and rejects a head with no labels.
154    AuxOutcome {
155        head: crate::decoders::behavioral_head::BehavioralHead,
156        /// ARD seed composed with the head, one log-precision per latent axis
157        /// (length `d`). `AuxOutcome` always carries the ARD axis-selection
158        /// alongside the behavioral anchor, since the label alone under-pins
159        /// the gauge. `None` defaults to a flat zero seed.
160        init_log_precision: Option<Array1<f64>>,
161    },
162    /// Anchor the latent configuration to a caller-supplied reference up to
163    /// the global isometry the chosen manifold already quotients out: penalty
164    /// `R_id = ½ μ · ‖t − reference‖²` with REML-selectable `μ` (the log-`μ`
165    /// normalizer enters the marginal likelihood exactly as in `AuxPrior`).
166    /// Unlike `AuxPrior`, the target is a fixed reference configuration (e.g. a
167    /// pilot embedding that fixes the isometry representative) rather than an
168    /// auxiliary-conditional mean `ĥ(u)`, so it pins the gauge with no
169    /// auxiliary signal `u`. `reference` has shape `(n_obs, d)`. As a standalone
170    /// anchor it is a valid gauge fix; it also composes with `DimSelection` ARD.
171    IsometryToReference {
172        reference: Array2<f64>,
173        strength: AuxPriorStrength,
174    },
175    /// No gauge fix. Inner Hessian is rank-deficient; results are not
176    /// uniquely defined. Intended only for the explicit "I supply my own
177    /// gauge constraint via the smoothing penalty" pathway.
178    None,
179}
180
181/// Natural manifold for per-row latent-coordinate updates.
182///
183/// `Euclidean` preserves the original additive update. `Circle` is a scalar
184/// angular coordinate wrapped modulo `2π`. `Sphere { dim }` is the embedded
185/// unit sphere in `R^dim`, with retraction `(t + ξ) / ||t + ξ||`. `Product`
186/// composes these blockwise; inside a product, `Euclidean` denotes one
187/// unconstrained scalar axis.
188#[derive(Debug, Clone, PartialEq, Default)]
189pub enum LatentManifold {
190    /// Unconstrained `R^d` — the current default.
191    #[default]
192    Euclidean,
193    /// Scalar periodic coordinate on `S^1` with caller-supplied period.
194    ///
195    /// Wraps modulo `period`; pass `period = 2π` for radian conventions and
196    /// `period = 1.0` for basis evaluators that interpret the latent as a
197    /// fraction of one period. The metric weight uses `1/period²` so the
198    /// trust-region radius respects the chosen unit.
199    Circle { period: f64 },
200    /// Embedded unit sphere `S^(dim-1)`.
201    Sphere { dim: usize },
202    /// Closed interval in `R`; the retraction clamps to the boundary.
203    Interval { lo: f64, hi: f64 },
204    /// Product manifold, split block-by-block in row-major ambient storage.
205    Product(Vec<LatentManifold>),
206    /// Product manifold with explicit per-axis trust-region metric weights.
207    ///
208    /// Without per-axis weighting, a Product of Circle + Interval treats
209    /// 1 radian as commensurate with the entire bounded range. With weights
210    /// = 1/scale², the trust-region radius respects each axis's natural unit.
211    ProductWithMetric {
212        manifolds: Vec<LatentManifold>,
213        weights: Vec<f64>,
214    },
215}
216
217impl LatentManifold {
218    pub fn is_euclidean(&self) -> bool {
219        matches!(self, Self::Euclidean)
220    }
221
222    /// Whether the Euclidean→Riemannian geometry transform applied by
223    /// `crate::solver::arrow_schur::ArrowSchurSystem::apply_riemannian_latent_geometry`
224    /// is the **identity** on the per-row gradient, `H_tt`, and `H_tβ` blocks
225    /// for *every* coordinate `t` on this chart.
226    ///
227    /// This is the exact condition under which a coupled Gauss-Newton block
228    /// `μ AᵀA = [[htt, cross],[crossᵀ, hbb]]` assembled from one residual
229    /// Jacobian survives the geometry pass with its PSD coherence intact: if
230    /// the transform leaves `htt` and the `htbeta` cross-block untouched, the
231    /// whole block is still `μ AᵀA` (PSD) and its Schur complement is PSD, so
232    /// the isometry cross-coupling can be kept (faster, exact Newton).
233    ///
234    /// A chart that rewrites `htt` with a curvature/connection term or
235    /// column-projects the cross-block (`Sphere`, an active `Interval`
236    /// boundary, any curved `Product` factor) breaks that pairing — the
237    /// cross-block is then no longer matched to diagonals from the same
238    /// Jacobian and the Schur complement can go indefinite (the #681
239    /// circle/sphere failure mode). Such charts must drop the cross-block.
240    ///
241    /// Flat charts (`Euclidean`, `Circle`, and `Product`s built only from
242    /// these) transform as the identity unconditionally — their tangent
243    /// projection is the identity, they carry no connection term, and they add
244    /// no normal pinning — so coherence is preserved and the cross-block is
245    /// kept. `Interval` is excluded: its tangent projection masks coordinates
246    /// at an active boundary (a `t`-dependent projection), which breaks the
247    /// pairing exactly like a curved chart.
248    pub fn preserves_isometry_cross_block_coherence(&self) -> bool {
249        match self {
250            Self::Euclidean | Self::Circle { .. } => true,
251            Self::Sphere { .. } | Self::Interval { .. } => false,
252            Self::Product(parts)
253            | Self::ProductWithMetric {
254                manifolds: parts, ..
255            } => parts
256                .iter()
257                .all(|part| part.preserves_isometry_cross_block_coherence()),
258        }
259    }
260
261    pub fn ambient_dim(&self, fallback_dim: usize) -> usize {
262        match self {
263            Self::Euclidean => fallback_dim,
264            Self::Circle { .. } | Self::Interval { .. } => 1,
265            Self::Sphere { dim } => *dim,
266            Self::Product(parts)
267            | Self::ProductWithMetric {
268                manifolds: parts, ..
269            } => parts.iter().map(|part| part.ambient_dim(1)).sum(),
270        }
271    }
272
273    /// Per-axis weights for the Riemannian trust-region metric.
274    ///
275    /// Defaults use `1/scale²`: Circle scale is `2π`, Sphere scale is `π`,
276    /// Interval scale is `hi - lo`, and Euclidean scale is `1`. Product
277    /// manifolds recurse and concatenate; [`Self::ProductWithMetric`] uses
278    /// the caller-supplied weights directly.
279    pub fn metric_weights(&self) -> Vec<f64> {
280        match self {
281            Self::Euclidean => vec![1.0],
282            Self::Circle { period } => {
283                assert!(
284                    period.is_finite() && *period > 0.0,
285                    "LatentManifold::Circle requires a finite positive period; got {period}"
286                );
287                vec![1.0 / (period * period)]
288            }
289            Self::Sphere { dim } => {
290                let w = 1.0 / (std::f64::consts::PI * std::f64::consts::PI);
291                vec![w; *dim]
292            }
293            Self::Interval { lo, hi } => {
294                let scale = hi - lo;
295                assert!(
296                    scale.is_finite() && scale > 0.0,
297                    "LatentManifold::Interval requires finite lo < hi; got lo={lo}, hi={hi}"
298                );
299                vec![1.0 / (scale * scale)]
300            }
301            Self::Product(parts) => {
302                let mut out = Vec::with_capacity(self.ambient_dim(1));
303                for part in parts {
304                    out.extend(part.metric_weights());
305                }
306                out
307            }
308            Self::ProductWithMetric { manifolds, weights } => {
309                let expected: usize = manifolds.iter().map(|part| part.ambient_dim(1)).sum();
310                assert_eq!(
311                    weights.len(),
312                    expected,
313                    "LatentManifold::ProductWithMetric weights length must match ambient dimension"
314                );
315                weights.clone()
316            }
317        }
318    }
319
320    /// Per-ambient-axis periodicity: `Some(period)` for an axis that wraps
321    /// modulo a finite period (a `Circle` factor, including the longitude of
322    /// the lat/lon sphere chart), `None` for a non-periodic axis (Euclidean,
323    /// Interval, or an embedded `Sphere` axis whose retraction is smooth and
324    /// has no cut).
325    ///
326    /// Used by the SAE-manifold ARD prior to switch from the cut-discontinuous
327    /// Euclidean `½α t²` to a smooth von-Mises energy on periodic axes. The
328    /// embedded `Sphere` is deliberately reported as non-periodic: its
329    /// retraction `(t+ξ)/‖t+ξ‖` is globally smooth, so the ambient `½α‖t‖²`
330    /// prior has no discontinuity there.
331    pub fn axis_periods(&self) -> Vec<Option<f64>> {
332        match self {
333            Self::Euclidean => vec![None],
334            Self::Circle { period } => {
335                assert!(
336                    period.is_finite() && *period > 0.0,
337                    "LatentManifold::Circle requires a finite positive period; got {period}"
338                );
339                vec![Some(*period)]
340            }
341            Self::Sphere { dim } => vec![None; *dim],
342            Self::Interval { .. } => vec![None],
343            Self::Product(parts) => {
344                let mut out = Vec::with_capacity(self.ambient_dim(1));
345                for part in parts {
346                    out.extend(part.axis_periods());
347                }
348                out
349            }
350            Self::ProductWithMetric { manifolds, .. } => {
351                let mut out = Vec::with_capacity(self.ambient_dim(1));
352                for part in manifolds {
353                    out.extend(part.axis_periods());
354                }
355                out
356            }
357        }
358    }
359
360    /// Project an arbitrary ambient point back to the manifold.
361    pub fn project_point(&self, t: ArrayView1<'_, f64>) -> Array1<f64> {
362        match self {
363            Self::Euclidean => t.to_owned(),
364            Self::Circle { period } => {
365                let mut out = Array1::<f64>::zeros(1);
366                out[0] = wrap_to_period(t[0], *period);
367                out
368            }
369            Self::Sphere { dim } => {
370                assert_eq!(t.len(), *dim);
371                normalize_or_axis(t, *dim)
372            }
373            Self::Interval { lo, hi } => {
374                // Order the bounds defensively: `f64::clamp` panics if min > max,
375                // so a reversed `Interval { lo, hi }` would otherwise crash deep
376                // in projection rather than clamp into the intended range.
377                let (lo, hi) = if lo <= hi { (*lo, *hi) } else { (*hi, *lo) };
378                let mut out = Array1::<f64>::zeros(1);
379                out[0] = t[0].clamp(lo, hi);
380                out
381            }
382            Self::Product(parts)
383            | Self::ProductWithMetric {
384                manifolds: parts, ..
385            } => {
386                let mut out = Array1::<f64>::zeros(t.len());
387                let mut offset = 0_usize;
388                for part in parts {
389                    let dim = part.ambient_dim(1);
390                    let projected = part.project_point(t.slice(ndarray::s![offset..offset + dim]));
391                    for a in 0..dim {
392                        out[offset + a] = projected[a];
393                    }
394                    offset += dim;
395                }
396                assert_eq!(offset, t.len());
397                out
398            }
399        }
400    }
401
402    /// Retraction `R_t(ξ)`, using closed-form analytic maps for every variant.
403    pub fn retract(&self, t: ArrayView1<'_, f64>, xi: ArrayView1<'_, f64>) -> Array1<f64> {
404        assert_eq!(t.len(), xi.len());
405        match self {
406            Self::Euclidean => {
407                let mut out = t.to_owned();
408                for a in 0..out.len() {
409                    out[a] += xi[a];
410                }
411                out
412            }
413            Self::Circle { period } => {
414                let mut out = Array1::<f64>::zeros(1);
415                out[0] = wrap_to_period(t[0] + xi[0], *period);
416                out
417            }
418            Self::Sphere { dim } => {
419                assert_eq!(t.len(), *dim);
420                let mut y = Array1::<f64>::zeros(*dim);
421                for a in 0..*dim {
422                    y[a] = t[a] + xi[a];
423                }
424                normalize_or_axis(y.view(), *dim)
425            }
426            Self::Interval { lo, hi } => {
427                // Order the bounds defensively: `f64::clamp` panics if min > max,
428                // so a reversed `Interval { lo, hi }` would otherwise crash the
429                // retraction instead of clamping into the intended range.
430                let (lo, hi) = if lo <= hi { (*lo, *hi) } else { (*hi, *lo) };
431                let mut out = Array1::<f64>::zeros(1);
432                out[0] = (t[0] + xi[0]).clamp(lo, hi);
433                out
434            }
435            Self::Product(parts)
436            | Self::ProductWithMetric {
437                manifolds: parts, ..
438            } => {
439                let mut out = Array1::<f64>::zeros(t.len());
440                let mut offset = 0_usize;
441                for part in parts {
442                    let dim = part.ambient_dim(1);
443                    let next = part.retract(
444                        t.slice(ndarray::s![offset..offset + dim]),
445                        xi.slice(ndarray::s![offset..offset + dim]),
446                    );
447                    for a in 0..dim {
448                        out[offset + a] = next[a];
449                    }
450                    offset += dim;
451                }
452                assert_eq!(offset, t.len());
453                out
454            }
455        }
456    }
457
458    /// Orthogonal projection of an ambient vector onto `T_t M`.
459    pub fn project_to_tangent(
460        &self,
461        t: ArrayView1<'_, f64>,
462        v: ArrayView1<'_, f64>,
463    ) -> Array1<f64> {
464        assert_eq!(t.len(), v.len());
465        match self {
466            Self::Euclidean | Self::Circle { .. } => v.to_owned(),
467            Self::Sphere { dim } => {
468                assert_eq!(t.len(), *dim);
469                let tv = t.dot(&v);
470                let mut out = v.to_owned();
471                for a in 0..*dim {
472                    out[a] -= tv * t[a];
473                }
474                out
475            }
476            Self::Interval { lo, hi } => {
477                let mut out = Array1::<f64>::zeros(1);
478                let at_lo = t[0] <= *lo && v[0] < 0.0;
479                let at_hi = t[0] >= *hi && v[0] > 0.0;
480                out[0] = if at_lo || at_hi { 0.0 } else { v[0] };
481                out
482            }
483            Self::Product(parts)
484            | Self::ProductWithMetric {
485                manifolds: parts, ..
486            } => {
487                let mut out = Array1::<f64>::zeros(v.len());
488                let mut offset = 0_usize;
489                for part in parts {
490                    let dim = part.ambient_dim(1);
491                    let projected = part.project_to_tangent(
492                        t.slice(ndarray::s![offset..offset + dim]),
493                        v.slice(ndarray::s![offset..offset + dim]),
494                    );
495                    for a in 0..dim {
496                        out[offset + a] = projected[a];
497                    }
498                    offset += dim;
499                }
500                assert_eq!(offset, v.len());
501                out
502            }
503        }
504    }
505
506    /// Project an objective gradient onto the linearized feasible update space.
507    ///
508    /// For smooth manifolds this is the usual tangent projection. For interval
509    /// endpoints the sign test is applied to the descent direction `-g`: at the
510    /// upper endpoint, a negative gradient would step outward, so the coordinate
511    /// is held fixed; at the lower endpoint, a positive gradient would step
512    /// outward. This is distinct from [`Self::project_to_tangent`], whose
513    /// interval branch projects update velocities.
514    pub fn project_gradient_to_tangent(
515        &self,
516        t: ArrayView1<'_, f64>,
517        g: ArrayView1<'_, f64>,
518    ) -> Array1<f64> {
519        assert_eq!(t.len(), g.len());
520        match self {
521            Self::Euclidean | Self::Circle { .. } | Self::Sphere { .. } => {
522                self.project_to_tangent(t, g)
523            }
524            Self::Interval { lo, hi } => {
525                let mut out = Array1::<f64>::zeros(1);
526                let descent_exits_lo = t[0] <= *lo && g[0] > 0.0;
527                let descent_exits_hi = t[0] >= *hi && g[0] < 0.0;
528                out[0] = if descent_exits_lo || descent_exits_hi {
529                    0.0
530                } else {
531                    g[0]
532                };
533                out
534            }
535            Self::Product(parts)
536            | Self::ProductWithMetric {
537                manifolds: parts, ..
538            } => {
539                let mut out = Array1::<f64>::zeros(g.len());
540                let mut offset = 0_usize;
541                for part in parts {
542                    let dim = part.ambient_dim(1);
543                    let projected = part.project_gradient_to_tangent(
544                        t.slice(ndarray::s![offset..offset + dim]),
545                        g.slice(ndarray::s![offset..offset + dim]),
546                    );
547                    for a in 0..dim {
548                        out[offset + a] = projected[a];
549                    }
550                    offset += dim;
551                }
552                // The per-part ambient widths (`part.ambient_dim(1)`) must tile
553                // `g` exactly. This holds because every `Product` is built in
554                // expanded scalar-factor form — a multi-dimensional Euclidean
555                // atom is stored as `d` single-axis `Euclidean` children, never
556                // one `d`-wide `Euclidean` (which `ambient_dim(1)` would
557                // under-count as one axis, mis-tiling a mixed-dimension composite
558                // and firing here — the #2295 zoo-fit panic). See
559                // `SaeManifoldTerm::append_coordinate_manifold_parts`.
560                assert_eq!(
561                    offset,
562                    g.len(),
563                    "Product factor ambient widths ({offset}) must tile the gradient ({}); a \
564                     Product must be in expanded scalar-factor form (see #2295)",
565                    g.len()
566                );
567                out
568            }
569        }
570    }
571
572    /// Project a coordinate-space Jacobian/cross-block column with the same
573    /// active interval coordinates selected by
574    /// [`Self::project_gradient_to_tangent`].
575    pub fn project_vector_to_gradient_tangent(
576        &self,
577        t: ArrayView1<'_, f64>,
578        g: ArrayView1<'_, f64>,
579        v: ArrayView1<'_, f64>,
580    ) -> Array1<f64> {
581        assert_eq!(t.len(), g.len());
582        assert_eq!(t.len(), v.len());
583        match self {
584            Self::Euclidean | Self::Circle { .. } | Self::Sphere { .. } => {
585                self.project_to_tangent(t, v)
586            }
587            Self::Interval { lo, hi } => {
588                let mut out = Array1::<f64>::zeros(1);
589                let descent_exits_lo = t[0] <= *lo && g[0] > 0.0;
590                let descent_exits_hi = t[0] >= *hi && g[0] < 0.0;
591                out[0] = if descent_exits_lo || descent_exits_hi {
592                    0.0
593                } else {
594                    v[0]
595                };
596                out
597            }
598            Self::Product(parts)
599            | Self::ProductWithMetric {
600                manifolds: parts, ..
601            } => {
602                let mut out = Array1::<f64>::zeros(v.len());
603                let mut offset = 0_usize;
604                for part in parts {
605                    let dim = part.ambient_dim(1);
606                    let projected = part.project_vector_to_gradient_tangent(
607                        t.slice(ndarray::s![offset..offset + dim]),
608                        g.slice(ndarray::s![offset..offset + dim]),
609                        v.slice(ndarray::s![offset..offset + dim]),
610                    );
611                    for a in 0..dim {
612                        out[offset + a] = projected[a];
613                    }
614                    offset += dim;
615                }
616                assert_eq!(offset, v.len());
617                out
618            }
619        }
620    }
621
622    /// Project every column of `matrix` with
623    /// [`Self::project_vector_to_gradient_tangent`].
624    pub fn project_matrix_columns_to_gradient_tangent(
625        &self,
626        t: ArrayView1<'_, f64>,
627        g: ArrayView1<'_, f64>,
628        matrix: ArrayView2<'_, f64>,
629    ) -> Array2<f64> {
630        let mut out = Array2::<f64>::zeros(matrix.dim());
631        assert_eq!(matrix.nrows(), t.len());
632        for col_idx in 0..matrix.ncols() {
633            let col = self.project_vector_to_gradient_tangent(t, g, matrix.column(col_idx));
634            for row_idx in 0..matrix.nrows() {
635                out[[row_idx, col_idx]] = col[row_idx];
636            }
637        }
638        out
639    }
640
641    /// Convert Euclidean Hessian action `eh · xi` to Riemannian Hessian action.
642    ///
643    /// For the sphere this is the Absil/Mahony/Sepulchre embedded-sphere
644    /// conversion: differentiate the projected gradient and project back to
645    /// the tangent space. The ambient derivative includes the normal
646    /// curvature term `-<grad_R, ξ> t`; the tangent action is equivalent to
647    /// `P_t(eh ξ) - <eg, t> ξ`.
648    pub fn euclidean_to_riemannian_hessian(
649        &self,
650        t: ArrayView1<'_, f64>,
651        eg: ArrayView1<'_, f64>,
652        eh: ArrayView2<'_, f64>,
653        xi: ArrayView1<'_, f64>,
654    ) -> Array1<f64> {
655        assert_eq!(t.len(), eg.len());
656        assert_eq!(t.len(), xi.len());
657        assert_eq!(eh.nrows(), t.len());
658        assert_eq!(eh.ncols(), t.len());
659        let eh_xi = eh.dot(&xi);
660        self.euclidean_hessian_action_to_riemannian(t, eg, xi, eh_xi.view())
661    }
662
663    fn euclidean_hessian_action_to_riemannian(
664        &self,
665        t: ArrayView1<'_, f64>,
666        eg: ArrayView1<'_, f64>,
667        xi: ArrayView1<'_, f64>,
668        eh_xi: ArrayView1<'_, f64>,
669    ) -> Array1<f64> {
670        assert_eq!(t.len(), eg.len());
671        assert_eq!(t.len(), xi.len());
672        assert_eq!(t.len(), eh_xi.len());
673        match self {
674            Self::Euclidean | Self::Circle { .. } => self.project_to_tangent(t, eh_xi),
675            Self::Interval { .. } => self.project_vector_to_gradient_tangent(t, eg, eh_xi),
676            Self::Sphere { dim } => {
677                assert_eq!(t.len(), *dim);
678                let grad_r = self.project_to_tangent(t, eg);
679                let mut ambient = self.project_to_tangent(t, eh_xi);
680                let eg_normal = eg.dot(&t);
681                let normal_curve = grad_r.dot(&xi);
682                for a in 0..*dim {
683                    ambient[a] -= eg_normal * xi[a];
684                    ambient[a] -= normal_curve * t[a];
685                }
686                self.project_to_tangent(t, ambient.view())
687            }
688            Self::Product(parts)
689            | Self::ProductWithMetric {
690                manifolds: parts, ..
691            } => {
692                let mut out = Array1::<f64>::zeros(t.len());
693                let mut offset = 0_usize;
694                for part in parts {
695                    let dim = part.ambient_dim(1);
696                    let converted = part.euclidean_hessian_action_to_riemannian(
697                        t.slice(ndarray::s![offset..offset + dim]),
698                        eg.slice(ndarray::s![offset..offset + dim]),
699                        xi.slice(ndarray::s![offset..offset + dim]),
700                        eh_xi.slice(ndarray::s![offset..offset + dim]),
701                    );
702                    for a in 0..dim {
703                        out[offset + a] = converted[a];
704                    }
705                    offset += dim;
706                }
707                assert_eq!(offset, t.len());
708                out
709            }
710        }
711    }
712
713    /// Dense ambient matrix representation of the tangent Hessian action.
714    ///
715    /// Normal directions are pinned with an identity block for embedded
716    /// constrained factors so existing BA Cholesky code can factor the ambient
717    /// matrix while RHS/cross blocks stay tangent-projected.
718    pub fn riemannian_hessian_matrix(
719        &self,
720        t: ArrayView1<'_, f64>,
721        eg: ArrayView1<'_, f64>,
722        eh: ArrayView2<'_, f64>,
723    ) -> Array2<f64> {
724        let d = t.len();
725        let mut out = Array2::<f64>::zeros((d, d));
726        let mut xi = Array1::<f64>::zeros(d);
727        for a in 0..d {
728            xi.fill(0.0);
729            xi[a] = 1.0;
730            let tangent_xi = self.project_vector_to_gradient_tangent(t, eg, xi.view());
731            let col = self.euclidean_to_riemannian_hessian(t, eg, eh, tangent_xi.view());
732            for b in 0..d {
733                out[[b, a]] = col[b];
734            }
735        }
736        self.add_normal_pinning(t, &mut out);
737        symmetrize(&mut out);
738        out
739    }
740
741    /// Project every column of an ambient matrix into `T_t M`.
742    pub fn project_matrix_columns_to_tangent(
743        &self,
744        t: ArrayView1<'_, f64>,
745        matrix: ArrayView2<'_, f64>,
746    ) -> Array2<f64> {
747        let mut out = Array2::<f64>::zeros(matrix.dim());
748        self.project_matrix_columns_to_tangent_into(t, matrix, out.view_mut());
749        out
750    }
751
752    /// In-place column-wise tangent projection: writes the projection of every
753    /// column of `matrix` into the matching column of `out`. Both `matrix` and
754    /// `out` must have shape `(ambient_dim × ncols)`. Callers that project the
755    /// same `(q × p)` scratch every row hoist `out` outside the loop to avoid
756    /// reallocating an `Array2` per row; the projection itself reuses the
757    /// allocation-free [`Self::project_to_tangent`] per column.
758    pub fn project_matrix_columns_to_tangent_into(
759        &self,
760        t: ArrayView1<'_, f64>,
761        matrix: ArrayView2<'_, f64>,
762        mut out: ndarray::ArrayViewMut2<'_, f64>,
763    ) {
764        assert_eq!(
765            matrix.dim(),
766            out.dim(),
767            "project_matrix_columns_to_tangent_into: matrix {:?} != out {:?}",
768            matrix.dim(),
769            out.dim(),
770        );
771        for col_idx in 0..matrix.ncols() {
772            let col = self.project_to_tangent(t, matrix.column(col_idx));
773            for row_idx in 0..matrix.nrows() {
774                out[[row_idx, col_idx]] = col[row_idx];
775            }
776        }
777    }
778
779    fn add_normal_pinning(&self, t: ArrayView1<'_, f64>, matrix: &mut Array2<f64>) {
780        match self {
781            Self::Sphere { dim } => {
782                assert_eq!(t.len(), *dim);
783                for a in 0..*dim {
784                    for b in 0..*dim {
785                        matrix[[a, b]] += SPHERE_NORMAL_PIN * t[a] * t[b];
786                    }
787                }
788            }
789            Self::Product(parts)
790            | Self::ProductWithMetric {
791                manifolds: parts, ..
792            } => {
793                let mut offset = 0_usize;
794                for part in parts {
795                    let dim = part.ambient_dim(1);
796                    let mut block =
797                        matrix.slice_mut(ndarray::s![offset..offset + dim, offset..offset + dim]);
798                    let mut owned = block.to_owned();
799                    part.add_normal_pinning(t.slice(ndarray::s![offset..offset + dim]), &mut owned);
800                    block.assign(&owned);
801                    offset += dim;
802                }
803            }
804            Self::Euclidean | Self::Circle { .. } | Self::Interval { .. } => {}
805        }
806    }
807}
808
809impl LatentIdMode {
810    /// Fixes the audit finding that ARD/DimSelection alone is rotation
811    /// symmetric and therefore not a standalone identifiability mode.
812    pub fn is_identifiable(&self) -> bool {
813        match self {
814            Self::AuxPrior { .. } | Self::AuxPriorDimSelection { .. } => true,
815            // A fixed-reference anchor pins the full gauge on its own.
816            Self::IsometryToReference { .. } => true,
817            // The behavioral head anchors the gauge through the label channel
818            // and always composes with ARD axis-selection; it is a standalone
819            // identifiable mode provided the head actually carries labels (an
820            // empty head pins nothing, rejected by `validate`).
821            Self::AuxOutcome { head, .. } => head.effective_labeled_count() > 0.0,
822            Self::DimSelection { .. } | Self::None => false,
823        }
824    }
825
826    /// Validate the mode's identifiability composition (issue #912 step 2).
827    ///
828    /// `AuxOutcome` must carry a non-vacuous head (at least one labeled row)
829    /// and composes with ARD — a bare label channel with no axis-selection
830    /// under-pins the gauge. Returns the offending reason on failure so the
831    /// builder can reject before fitting. (The former `reject_dim_selection_alone`
832    /// guard was unified here into the Result path for a panic-free gate.)
833    pub fn validate(&self) -> Result<(), String> {
834        if matches!(self, Self::DimSelection { .. }) {
835            // `DimSelection` alone is rotation-symmetric — not a valid
836            // gauge fix; callers must pair ARD with `AuxPrior`/`Isometry`.
837            // Beautiful unification: return a proper error instead of a
838            // panic guard (removes the tracked ban stub while keeping the
839            // gate).
840            return Err("LatentIdMode::DimSelection is not a standalone gauge fix; \
841                 pair ARD with AuxPrior or Isometry"
842                .to_string());
843        }
844        if let Self::AuxOutcome { head, .. } = self
845            && head.effective_labeled_count() <= 0.0
846        {
847            return Err(
848                "LatentIdMode::AuxOutcome: the behavioral head has no labeled rows \
849                 (Σ row-weights = 0); a label-free head pins no gauge dimension. \
850                 Provide labels or use AuxPrior/DimSelection composition."
851                    .to_string(),
852            );
853        }
854        Ok(())
855    }
856}
857
858/// Carrier for the `∂Φ/∂t` chain-rule input, dispatched on basis kind by
859/// [`LatentCoordValues::design_gradient_wrt_t_dispatch`].
860///
861/// * [`InputLocationDerivative::Radial`] is the *radial-kernel* path: the
862///   caller supplies the radial kernel family together with the center
863///   coordinates, and the chain rule
864///   `∂Φ/∂t = q(r) · (t − c)` is applied internally. This covers every
865///   isotropic radial basis — Duchon (any nullspace order), Matérn (every
866///   supported half-integer ν), and anything else whose pointwise
867///   gradient is radial. Helpers:
868///   [`crate::basis::duchon_radial_first_derivative_nd`],
869///   [`crate::basis::matern_radial_first_derivative_nd`].
870/// * [`InputLocationDerivative::Jet`] is the *pre-computed jet* path: the
871///   caller has already assembled a closed-form `(N, K, d)` tensor for a
872///   basis whose chain rule is not a simple radial scalar times a unit
873///   vector. Sphere kernels carry the tangent-direction times `K'(cos γ)`;
874///   periodic-cyclic B-splines carry the closed-form cardinal derivative;
875///   tensor-product B-splines carry the product-rule mix. Helpers:
876///   [`crate::basis::sphere_first_derivative_nd`],
877///   [`crate::basis::periodic_bspline_first_derivative_nd`],
878///   [`crate::basis::bspline_tensor_first_derivative`].
879///
880/// The dispatch is an enum rather than a trait because each path's
881/// arguments differ structurally (radial bases reuse scalar radial kernels shared with
882/// the kernel-shape chain machinery; jet bases ship the full tensor). All chain rules
883/// are analytic and closed-form; no autodiff, no finite differences.
884pub enum InputLocationDerivative<'a> {
885    /// Radial-kernel chain rule. The chain rule `(t − c)/r` is reconstructed
886    /// internally from the finite `q = φ'(r)/r` scalar and the center coordinates.
887    Radial {
888        centers: ArrayView2<'a, f64>,
889        radial_kind: &'a RadialScalarKind,
890    },
891    /// Pre-computed analytic `(n_obs, n_centers, latent_dim)` jet.
892    Jet(ArrayView3<'a, f64>),
893}
894
895/// Per-row latent coordinates `t ∈ ℝ^{N × d}` stored as a flat
896/// row-major `Array1<f64>` of length `n_obs * latent_dim`.
897///
898/// The flat-`Array1` layout mirrors [`crate::smooth::SpatialLogKappaCoords`]
899/// so the same `HyperDesignDerivative::from_implicit` / `DirectionalHyperParam`
900/// outer plumbing can consume it without modification.
901#[derive(Debug, Clone)]
902pub struct LatentCoordValues {
903    /// Stable process-local identity for this latent-coordinate block.
904    id: u64,
905    /// Flattened (n_obs, latent_dim) latent matrix, row-major
906    /// (so `values[n * d + k] = t_n[k]`).
907    values: Array1<f64>,
908    /// Number of rows `N`.
909    n_obs: usize,
910    /// Number of latent dimensions `d`.
911    latent_dim: usize,
912    /// Identifiability / gauge-fix mode.
913    id_mode: LatentIdMode,
914    /// Manifold used for per-row Riemannian updates.
915    manifold: LatentManifold,
916    /// Explicit update-side retraction. The empty registry is Euclidean.
917    retraction_registry: LatentRetractionRegistry,
918}
919
920impl LatentCoordValues {
921    /// Construct from a dense `(n_obs, latent_dim)` matrix.
922    pub fn from_matrix(matrix: ArrayView2<'_, f64>, id_mode: LatentIdMode) -> Self {
923        Self::from_matrix_with_manifold(matrix, id_mode, LatentManifold::Euclidean)
924    }
925
926    /// Construct from a dense matrix and explicit latent manifold.
927    pub fn from_matrix_with_manifold(
928        matrix: ArrayView2<'_, f64>,
929        id_mode: LatentIdMode,
930        manifold: LatentManifold,
931    ) -> Self {
932        Self::from_matrix_with_manifold_and_retraction(
933            matrix,
934            id_mode,
935            manifold,
936            LatentRetractionRegistry::all_euclidean(),
937        )
938    }
939
940    pub fn from_matrix_with_manifold_and_retraction(
941        matrix: ArrayView2<'_, f64>,
942        id_mode: LatentIdMode,
943        manifold: LatentManifold,
944        retraction_registry: LatentRetractionRegistry,
945    ) -> Self {
946        id_mode
947            .validate()
948            .expect("invalid LatentIdMode for LatentCoordValues::from_matrix_with_manifold");
949        let n_obs = matrix.nrows();
950        let latent_dim = matrix.ncols();
951        retraction_registry
952            .validate_dim(latent_dim, "LatentCoordValues::from_matrix_with_manifold")
953            .expect("invalid latent retraction dimension");
954        let mut values = Array1::<f64>::zeros(n_obs * latent_dim);
955        for n in 0..n_obs {
956            for k in 0..latent_dim {
957                values[n * latent_dim + k] = matrix[[n, k]];
958            }
959        }
960        let mut out = Self {
961            id: next_latent_coord_id(),
962            values,
963            n_obs,
964            latent_dim,
965            id_mode,
966            manifold,
967            retraction_registry,
968        };
969        out.project_all_rows_to_manifold();
970        out
971    }
972
973    /// Construct directly from a flat (`n_obs * latent_dim`) array.
974    pub fn from_flat(
975        values: Array1<f64>,
976        n_obs: usize,
977        latent_dim: usize,
978        id_mode: LatentIdMode,
979    ) -> Self {
980        Self::from_flat_with_manifold(
981            values,
982            n_obs,
983            latent_dim,
984            id_mode,
985            LatentManifold::Euclidean,
986        )
987    }
988
989    /// Construct directly from a flat array and explicit latent manifold.
990    pub fn from_flat_with_manifold(
991        values: Array1<f64>,
992        n_obs: usize,
993        latent_dim: usize,
994        id_mode: LatentIdMode,
995        manifold: LatentManifold,
996    ) -> Self {
997        Self::from_flat_with_manifold_and_retraction_and_id(
998            values,
999            n_obs,
1000            latent_dim,
1001            id_mode,
1002            manifold,
1003            LatentRetractionRegistry::all_euclidean(),
1004            next_latent_coord_id(),
1005        )
1006    }
1007
1008    pub fn from_flat_with_manifold_and_retraction_and_id(
1009        values: Array1<f64>,
1010        n_obs: usize,
1011        latent_dim: usize,
1012        id_mode: LatentIdMode,
1013        manifold: LatentManifold,
1014        retraction_registry: LatentRetractionRegistry,
1015        id: u64,
1016    ) -> Self {
1017        id_mode
1018            .validate()
1019            .expect("invalid LatentIdMode for LatentCoordValues::from_flat");
1020        assert_eq!(
1021            values.len(),
1022            n_obs * latent_dim,
1023            "LatentCoordValues::from_flat: length {} != n_obs * latent_dim = {}",
1024            values.len(),
1025            n_obs * latent_dim
1026        );
1027        retraction_registry
1028            .validate_dim(latent_dim, "LatentCoordValues::from_flat_with_manifold")
1029            .expect("invalid latent retraction dimension");
1030        let mut out = Self {
1031            id,
1032            values,
1033            n_obs,
1034            latent_dim,
1035            id_mode,
1036            manifold,
1037            retraction_registry,
1038        };
1039        out.project_all_rows_to_manifold();
1040        out
1041    }
1042
1043    pub fn latent_id(&self) -> u64 {
1044        self.id
1045    }
1046
1047    pub fn n_obs(&self) -> usize {
1048        self.n_obs
1049    }
1050
1051    pub fn latent_dim(&self) -> usize {
1052        self.latent_dim
1053    }
1054
1055    /// Total length of the flat value array (= `n_obs * latent_dim`).
1056    pub fn len(&self) -> usize {
1057        self.values.len()
1058    }
1059
1060    pub fn is_empty(&self) -> bool {
1061        self.values.is_empty()
1062    }
1063
1064    pub fn id_mode(&self) -> &LatentIdMode {
1065        &self.id_mode
1066    }
1067
1068    pub fn manifold(&self) -> &LatentManifold {
1069        &self.manifold
1070    }
1071
1072    pub fn retraction_registry(&self) -> &LatentRetractionRegistry {
1073        &self.retraction_registry
1074    }
1075
1076    /// Effective "is all Euclidean" check used by the inner solver:
1077    /// returns `true` only when *both* the declared `LatentManifold` and the
1078    /// optional override retraction registry are Euclidean. The registry's
1079    /// own `is_all_euclidean` answers a strictly narrower question (was an
1080    /// explicit non-Euclidean override installed?) and would silently miss
1081    /// non-Euclidean manifolds installed via `from_matrix_with_manifold` /
1082    /// `with_manifold`, which left the registry at its `all_euclidean`
1083    /// default. See `retract_flat_delta` for the matching update path.
1084    pub fn effective_is_all_euclidean(&self) -> bool {
1085        self.manifold.is_euclidean() && self.retraction_registry.is_all_euclidean()
1086    }
1087
1088    /// Effective per-axis trust-region metric weights. When the manifold is
1089    /// non-Euclidean it is the authoritative geometric description (it
1090    /// covers `Interval` and `ProductWithMetric`, which the registry's
1091    /// `RetractionKind` cannot express), so we read weights from it. When
1092    /// the manifold is Euclidean but an explicit override retraction was
1093    /// supplied (e.g. via the JSON `retraction:` key) the registry's
1094    /// weights win.
1095    pub fn effective_metric_weights(&self) -> Vec<f64> {
1096        if self.manifold.is_euclidean() {
1097            self.retraction_registry.metric_weights(self.latent_dim)
1098        } else {
1099            self.manifold.metric_weights()
1100        }
1101    }
1102
1103    /// Effective per-axis periodicity (`Some(period)` on wrapped axes). When
1104    /// the declared manifold is non-Euclidean it is authoritative; when it is
1105    /// Euclidean, an explicit override retraction (if any) decides. Returns a
1106    /// `Vec` of length `latent_dim`.
1107    pub fn effective_axis_periods(&self) -> Vec<Option<f64>> {
1108        let periods = if self.manifold.is_euclidean() {
1109            self.retraction_registry.axis_periods(self.latent_dim)
1110        } else {
1111            self.manifold.axis_periods()
1112        };
1113        assert_eq!(
1114            periods.len(),
1115            self.latent_dim,
1116            "effective_axis_periods length {} != latent_dim {}",
1117            periods.len(),
1118            self.latent_dim
1119        );
1120        periods
1121    }
1122
1123    pub fn with_manifold(&self, manifold: LatentManifold) -> Self {
1124        Self::from_flat_with_manifold_and_retraction_and_id(
1125            self.values.clone(),
1126            self.n_obs,
1127            self.latent_dim,
1128            self.id_mode.clone(),
1129            manifold,
1130            self.retraction_registry.clone(),
1131            self.id,
1132        )
1133    }
1134
1135    /// View the flat value array.
1136    pub fn as_flat(&self) -> &Array1<f64> {
1137        &self.values
1138    }
1139
1140    /// View row `n` as a length-`d` slice.
1141    pub fn row(&self, n: usize) -> &[f64] {
1142        let start = n * self.latent_dim;
1143        let end = start + self.latent_dim;
1144        &self.values.as_slice().expect("contiguous")[start..end]
1145    }
1146
1147    /// Materialize as a dense `(n_obs, latent_dim)` matrix view.
1148    /// Useful when handing `t` to a row-major basis evaluator
1149    /// (e.g. `build_duchon_basis`).
1150    pub fn as_matrix(&self) -> Array2<f64> {
1151        let mut out = Array2::<f64>::zeros((self.n_obs, self.latent_dim));
1152        for n in 0..self.n_obs {
1153            for k in 0..self.latent_dim {
1154                out[[n, k]] = self.values[n * self.latent_dim + k];
1155            }
1156        }
1157        out
1158    }
1159
1160    /// Mutable write back of the flat value array, e.g. after a Newton step.
1161    pub fn set_flat(&mut self, flat: ArrayView1<'_, f64>) {
1162        assert_eq!(flat.len(), self.values.len());
1163        self.values.assign(&flat);
1164        self.project_all_rows_to_manifold();
1165    }
1166
1167    /// Apply a flat tangent update row-by-row through the manifold retraction.
1168    pub fn retract_flat_delta(&mut self, delta: ArrayView1<'_, f64>) {
1169        assert_eq!(delta.len(), self.values.len());
1170        if self.retraction_registry.is_all_euclidean() {
1171            if self.manifold.is_euclidean() {
1172                for (t, dt) in self.values.iter_mut().zip(delta.iter()) {
1173                    *t += *dt;
1174                }
1175                return;
1176            }
1177            assert_eq!(
1178                self.manifold.ambient_dim(self.latent_dim),
1179                self.latent_dim,
1180                "LatentCoordValues::retract_flat_delta: manifold ambient dim does not match latent_dim",
1181            );
1182            for n in 0..self.n_obs {
1183                let start = n * self.latent_dim;
1184                let end = start + self.latent_dim;
1185                let next = self.manifold.retract(
1186                    self.values.slice(ndarray::s![start..end]),
1187                    delta.slice(ndarray::s![start..end]),
1188                );
1189                for a in 0..self.latent_dim {
1190                    self.values[start + a] = next[a];
1191                }
1192            }
1193            return;
1194        }
1195        for n in 0..self.n_obs {
1196            let start = n * self.latent_dim;
1197            let end = start + self.latent_dim;
1198            let mut current = self.values.slice_mut(ndarray::s![start..end]);
1199            let xi = delta.slice(ndarray::s![start..end]);
1200            self.retraction_registry.retract(&mut current, xi);
1201        }
1202    }
1203
1204    fn project_all_rows_to_manifold(&mut self) {
1205        if self.manifold.is_euclidean() {
1206            return;
1207        }
1208        assert_eq!(self.manifold.ambient_dim(self.latent_dim), self.latent_dim);
1209        for n in 0..self.n_obs {
1210            let start = n * self.latent_dim;
1211            let end = start + self.latent_dim;
1212            let projected = self
1213                .manifold
1214                .project_point(self.values.slice(ndarray::s![start..end]));
1215            for a in 0..self.latent_dim {
1216                self.values[start + a] = projected[a];
1217            }
1218        }
1219    }
1220
1221    /// Apply this latent block back to a `TermCollectionSpec`-style covariate
1222    /// table: returns the `(N, d)` materialized matrix that downstream basis
1223    /// evaluators (Duchon, Matérn, ...) take as their feature input.
1224    ///
1225    /// This mirrors [`crate::smooth::SpatialLogKappaCoords::apply_tospec`],
1226    /// but the carrier on the spec side is the data-row covariate block rather
1227    /// than the per-term `length_scale`. The spec-mutation is handled at the
1228    /// call site (the consuming term needs to know which columns of its
1229    /// feature view to overwrite).
1230    pub fn apply_tospec(&self) -> Array2<f64> {
1231        self.as_matrix()
1232    }
1233
1234    /// Compute `∂Φ/∂t` for a radial-kernel design Φ — the original
1235    /// Duchon/Matérn path. See [`Self::design_gradient_wrt_t_dispatch`] for
1236    /// the basis-agnostic dispatch entry point.
1237    ///
1238    /// `centers` is `(n_centers, d)`.
1239    /// Returns a `(n_obs, n_centers, d)` jet whose `(n, k, a)` entry is
1240    /// `∂Φ_{n,k} / ∂t_{n,a} = q(r_{n,k}) · (t_{n,a} − c_{k,a})`.
1241    ///
1242    /// At `r = 0` the unit vector `(t − c)/r` is undefined; the radial scalar
1243    /// path therefore asks the kernel for the finite `q` limit and surfaces
1244    /// `BasisError::DegenerateAtCollision` when that limit does not exist.
1245    pub(crate) fn design_gradient_wrt_t(
1246        &self,
1247        centers: ArrayView2<'_, f64>,
1248        radial_kind: &RadialScalarKind,
1249    ) -> Result<Array3<f64>, BasisError> {
1250        let n_obs = self.n_obs;
1251        let d = self.latent_dim;
1252        let n_centers = centers.nrows();
1253        if centers.ncols() != d {
1254            crate::bail_dim_basis!(
1255                "LatentCoordValues::design_gradient_wrt_t center dimension mismatch: centers have {} cols but latent_dim is {}",
1256                centers.ncols(),
1257                d
1258            );
1259        }
1260        let mut jet = Array3::<f64>::zeros((n_obs, n_centers, d));
1261        for n in 0..n_obs {
1262            let t_n = self.row(n);
1263            for k in 0..n_centers {
1264                let mut r2 = 0.0_f64;
1265                for a in 0..d {
1266                    let delta = t_n[a] - centers[[k, a]];
1267                    r2 += delta * delta;
1268                }
1269                let r = r2.sqrt();
1270                let (_, q, _) = radial_kind.eval_design_triplet(r)?;
1271                if q == 0.0 {
1272                    continue;
1273                }
1274                for a in 0..d {
1275                    jet[[n, k, a]] = q * (t_n[a] - centers[[k, a]]);
1276                }
1277            }
1278        }
1279        Ok(jet)
1280    }
1281
1282    /// Compute `∂Φ/∂t` for an arbitrary supported basis kind, by dispatching
1283    /// to the right closed-form chain rule.
1284    ///
1285    /// All radial-kernel bases (Duchon, Matérn) reduce to the same
1286    /// `q(r) · (t − c)` chain that `design_gradient_wrt_t` already implements.
1287    /// Non-radial bases (sphere, periodic-cyclic B-spline, tensor
1288    /// B-spline) carry their own analytic `(N, K, d)` jet — the caller
1289    /// pre-builds that jet using the matching `*_first_derivative_nd` helper
1290    /// in [`crate::basis`] and passes it in via
1291    /// [`InputLocationDerivative::Jet`].
1292    ///
1293    /// This is the single entry point the outer optimizer should call; it
1294    /// stays in lock-step with the kernel-parameter chain rule that
1295    /// `SpatialLogKappaCoords` uses (re-pointed at the first kernel argument
1296    /// rather than at kernel anisotropy).
1297    pub(crate) fn design_gradient_wrt_t_dispatch(
1298        &self,
1299        input: InputLocationDerivative<'_>,
1300    ) -> Result<Array3<f64>, BasisError> {
1301        match input {
1302            InputLocationDerivative::Radial {
1303                centers,
1304                radial_kind,
1305            } => self.design_gradient_wrt_t(centers, radial_kind),
1306            InputLocationDerivative::Jet(jet) => {
1307                if jet.shape() != [self.n_obs, jet.shape()[1], self.latent_dim] {
1308                    crate::bail_dim_basis!(
1309                        "LatentCoordValues::design_gradient_wrt_t_dispatch jet shape {:?} does not match latent shape ({}, {}, {})",
1310                        jet.shape(),
1311                        self.n_obs,
1312                        jet.shape()[1],
1313                        self.latent_dim
1314                    );
1315                }
1316                // The non-radial helpers already produce a (N, K, d) tensor
1317                // in the layout downstream contraction consumes. Return a copy
1318                // so the caller owns the data and is decoupled from the source
1319                // array's lifetime.
1320                Ok(jet.to_owned())
1321            }
1322        }
1323    }
1324}
1325
1326/// Minimum total active assignment mass a per-row atom code must retain after a
1327/// Whether the active assignment mass of a per-row code is degenerate
1328/// (non-finite), i.e. a NaN/Inf assignment.
1329///
1330/// #1074: the magic `LATENT_ACTIVE_MASS_FLOOR = 1e-6` collapse-detect-and-reseed
1331/// floor was DELETED. It masked the real defect — rows being driven dark by the
1332/// assignment optimizer — with a detect-then-reseed bandaid rather than
1333/// preventing the collapse. Only the genuine NaN/Inf guard remains. The proper
1334/// fix (prevent the active-set collapse in the assignment step) is tracked
1335/// separately.
1336///
1337/// `active_weights` is the slice of per-atom assignment weights on the row's
1338/// active support. Returns `true` only when the summed magnitude is non-finite.
1339pub fn active_mass_breached(active_weights: &[f64]) -> bool {
1340    let mut mass = 0.0_f64;
1341    for &w in active_weights {
1342        mass += w.abs();
1343    }
1344    !mass.is_finite()
1345}
1346
1347fn wrap_to_period(x: f64, period: f64) -> f64 {
1348    assert!(
1349        period.is_finite() && period > 0.0,
1350        "wrap_to_period requires a finite positive period; got {period}"
1351    );
1352    let y = x.rem_euclid(period);
1353    if y == period { 0.0 } else { y }
1354}
1355
1356/// Normalize `v[0..dim]` to a unit vector (for `LatentManifold::Sphere`
1357/// projection and retraction).
1358///
1359/// "Or axis": if the input is zero or non-finite (degenerate or numerical
1360/// mishap in caller), gracefully fall back to the canonical first axis
1361/// unit vector `[1, 0, …, 0]`. This removes a hard panic while preserving
1362/// the sphere contract that every returned point has unit Euclidean norm.
1363/// Callers (project_point / retract on Sphere) already ensure dim matches
1364/// the view length for the manifold component.
1365fn normalize_or_axis(v: ArrayView1<'_, f64>, dim: usize) -> Array1<f64> {
1366    let mut norm_sq = 0.0_f64;
1367    for a in 0..dim {
1368        norm_sq += v[a] * v[a];
1369    }
1370    const EPS: f64 = 1e-300; // protect against underflow/denorm that would give Inf
1371    if norm_sq > EPS && norm_sq.is_finite() {
1372        let inv = 1.0 / norm_sq.sqrt();
1373        let mut out = Array1::<f64>::zeros(dim);
1374        for a in 0..dim {
1375            out[a] = v[a] * inv;
1376        }
1377        out
1378    } else {
1379        // "or axis" fallback — beautiful, non-panicking resolution for
1380        // degenerate ambient vector on the sphere.
1381        let mut out = Array1::<f64>::zeros(dim);
1382        if dim > 0 {
1383            out[0] = 1.0;
1384        }
1385        out
1386    }
1387}
1388
1389#[inline]
1390fn symmetrize(a: &mut Array2<f64>) {
1391    // Callers in this module always pass square (d, d) matrices; delegate to
1392    // the canonical helper in `linalg::utils`.
1393    gam_linalg::matrix::symmetrize_in_place(a)
1394}
1395
1396/// Auxiliary-prior penalty contribution: returns the per-row reference
1397/// coordinates `ĥ(u_n)` shape `(n_obs, d)` and the effective strength `μ`.
1398///
1399/// `t_target` is broadcast across the inner ridge of `½ μ · ‖t − t_target‖²`,
1400/// which the call site folds into the Y-stack via a virtual-row augmentation
1401/// (`y' = [y; √μ · t_target]`, `X' = [X; √μ · I_d ⊗ row-block]`). This
1402/// keeps the inner solver Gaussian-closed-form.
1403///
1404/// For `AuxPriorFamily::Ridge` the conditional mean is the closed-form ridge
1405/// regression `(UᵀU + ε I)⁻¹ UᵀT` evaluated at each row's `u_n`. For
1406/// `Linear` the ridge is zero (which raises if `UᵀU` is singular).
1407/// Closed-form auxiliary-prior REML statistics at a fixed outer coordinate `t`.
1408pub struct AuxPriorRemlStats {
1409    pub residual_sq: f64,
1410    pub log_mu: f64,
1411    pub mu: f64,
1412    pub auto: bool,
1413    pub score: f64,
1414}
1415
1416/// Auxiliary-prior REML statistics for a fixed outer coordinate `t`, given the
1417/// precomputed `targets` (see [`aux_prior_targets`]). Returns the residual sum of
1418/// squares, the precision `mu` (the supplied `aux_strength` when `Some`, else the
1419/// closed-form REML optimum `mu = K / Σr²`), whether it was auto-selected, and
1420/// the prior score `0.5·mu·Σr² − 0.5·K·ln(mu)`. The `log_mu` coordinate has this
1421/// closed-form optimum at fixed `t` because only the normalized auxiliary prior
1422/// depends on it.
1423///
1424/// `K = n_obs · latent_dim` is the number of scalar latent coordinates the single
1425/// shared precision `mu` governs. The normalizer term `−0.5·K·ln(mu)` is the prior
1426/// log-determinant `−0.5·log det₊(mu · I_K)`, so it counts every governed
1427/// coordinate. Counting only `n_obs` undercounts a `latent_dim`-dimensional latent
1428/// by exactly `latent_dim`, which biases the REML precision toward under-shrinkage
1429/// (the per-axis ARD path emits `−0.5·n_obs·ln(α)` for each of `latent_dim` axes;
1430/// a single shared `mu` must match that sum).
1431pub fn aux_prior_reml_stats(
1432    t_mat: ArrayView2<'_, f64>,
1433    targets: ArrayView2<'_, f64>,
1434    aux_strength: Option<f64>,
1435) -> Result<AuxPriorRemlStats, String> {
1436    let n_obs = t_mat.nrows();
1437    let latent_dim = t_mat.ncols();
1438    let mut residual_sq = 0.0_f64;
1439    for n in 0..n_obs {
1440        for a in 0..latent_dim {
1441            let diff = t_mat[[n, a]] - targets[[n, a]];
1442            residual_sq += diff * diff;
1443        }
1444    }
1445    if !residual_sq.is_finite() {
1446        return Err("auxiliary prior residual norm must be finite".to_string());
1447    }
1448    let (log_mu, mu, auto) = match aux_strength {
1449        Some(mu) => {
1450            if !(mu.is_finite() && mu > 0.0) {
1451                return Err(format!(
1452                    "aux_strength must be finite and positive; got {mu}"
1453                ));
1454            }
1455            (mu.ln(), mu, false)
1456        }
1457        None => {
1458            if residual_sq <= 0.0 {
1459                return Err(
1460                    "aux_strength='auto' has no finite REML optimum when the auxiliary residual is zero"
1461                        .to_string(),
1462                );
1463            }
1464            let mu = ((n_obs * latent_dim) as f64) / residual_sq;
1465            if !(mu.is_finite() && mu > 0.0) {
1466                return Err(format!(
1467                    "auto aux_strength selected a non-finite precision: {mu}"
1468                ));
1469            }
1470            (mu.ln(), mu, true)
1471        }
1472    };
1473    let score = 0.5 * mu * residual_sq - 0.5 * ((n_obs * latent_dim) as f64) * log_mu;
1474    Ok(AuxPriorRemlStats {
1475        residual_sq,
1476        log_mu,
1477        mu,
1478        auto,
1479        score,
1480    })
1481}
1482
1483pub fn aux_prior_targets(
1484    t: ArrayView2<'_, f64>,
1485    u: ArrayView2<'_, f64>,
1486    family: AuxPriorFamily,
1487) -> Result<Array2<f64>, String> {
1488    let n_obs = t.nrows();
1489    let d = t.ncols();
1490    if u.nrows() != n_obs {
1491        return Err(format!(
1492            "aux_prior_targets: u has {} rows but t has {}",
1493            u.nrows(),
1494            n_obs
1495        ));
1496    }
1497    let p = u.ncols();
1498    if p == 0 {
1499        return Err("aux_prior_targets: auxiliary u must have at least one column".into());
1500    }
1501    // gram = UᵀU  (p × p)
1502    let mut gram = Array2::<f64>::zeros((p, p));
1503    for n in 0..n_obs {
1504        for i in 0..p {
1505            for j in 0..p {
1506                gram[[i, j]] += u[[n, i]] * u[[n, j]];
1507            }
1508        }
1509    }
1510    let ridge_eps = match family {
1511        AuxPriorFamily::Ridge => {
1512            let trace: f64 = (0..p).map(|i| gram[[i, i]]).sum();
1513            (1e-6 * trace / p as f64).max(1e-12)
1514        }
1515        AuxPriorFamily::Linear => 0.0,
1516    };
1517    for i in 0..p {
1518        gram[[i, i]] += ridge_eps;
1519    }
1520    // rhs = UᵀT  (p × d)
1521    let mut rhs = Array2::<f64>::zeros((p, d));
1522    for n in 0..n_obs {
1523        for i in 0..p {
1524            for k in 0..d {
1525                rhs[[i, k]] += u[[n, i]] * t[[n, k]];
1526            }
1527        }
1528    }
1529    let coeffs = solve_spd(gram.view(), rhs.view())?;
1530    // targets = U · coeffs  (n_obs × d)
1531    let mut targets = Array2::<f64>::zeros((n_obs, d));
1532    for n in 0..n_obs {
1533        for k in 0..d {
1534            let mut acc = 0.0_f64;
1535            for i in 0..p {
1536                acc += u[[n, i]] * coeffs[[i, k]];
1537            }
1538            targets[[n, k]] = acc;
1539        }
1540    }
1541    Ok(targets)
1542}
1543
1544/// Lightweight Cholesky-based SPD solve. Keeps this module dependency-free
1545/// from the broader faer-wrapping surface; matrices here are tiny
1546/// (`p × p` with p = aux-feature count, typically O(10)).
1547fn solve_spd(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
1548    let n = a.nrows();
1549    if a.ncols() != n {
1550        return Err("solve_spd: A must be square".into());
1551    }
1552    if b.nrows() != n {
1553        return Err("solve_spd: RHS row count mismatch".into());
1554    }
1555    // In-place Cholesky factorization. We pay the O(n³) copy + O(n³) factor
1556    // up front; n is tiny in the auxiliary-prior path.
1557    let mut l = Array2::<f64>::zeros((n, n));
1558    for i in 0..n {
1559        for j in 0..=i {
1560            let mut sum = a[[i, j]];
1561            for k in 0..j {
1562                sum -= l[[i, k]] * l[[j, k]];
1563            }
1564            if i == j {
1565                if sum <= 0.0 {
1566                    return Err(format!(
1567                        "solve_spd: non-positive pivot {sum} at index {i} \
1568                         (matrix is not positive definite)"
1569                    ));
1570                }
1571                l[[i, j]] = sum.sqrt();
1572            } else {
1573                l[[i, j]] = sum / l[[j, j]];
1574            }
1575        }
1576    }
1577    // Solve L y = b, then Lᵀ x = y, column by column.
1578    let d = b.ncols();
1579    let mut out = Array2::<f64>::zeros((n, d));
1580    for col in 0..d {
1581        let mut y = Array1::<f64>::zeros(n);
1582        for i in 0..n {
1583            let mut sum = b[[i, col]];
1584            for k in 0..i {
1585                sum -= l[[i, k]] * y[k];
1586            }
1587            y[i] = sum / l[[i, i]];
1588        }
1589        for i in (0..n).rev() {
1590            let mut sum = y[i];
1591            for k in (i + 1)..n {
1592                sum -= l[[k, i]] * out[[k, col]];
1593            }
1594            out[[i, col]] = sum / l[[i, i]];
1595        }
1596    }
1597    Ok(out)
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603    use ndarray::array;
1604
1605    #[test]
1606    fn from_matrix_roundtrip() {
1607        let m = array![[1.0_f64, 2.0], [3.0, 4.0], [5.0, 6.0]];
1608        let lc = LatentCoordValues::from_matrix(m.view(), LatentIdMode::None);
1609        assert_eq!(lc.n_obs(), 3);
1610        assert_eq!(lc.latent_dim(), 2);
1611        let back = lc.as_matrix();
1612        assert_eq!(back, m);
1613    }
1614
1615    #[test]
1616    fn row_access() {
1617        let m = array![[1.0_f64, 2.0], [3.0, 4.0]];
1618        let lc = LatentCoordValues::from_matrix(m.view(), LatentIdMode::None);
1619        assert_eq!(lc.row(0), &[1.0, 2.0]);
1620        assert_eq!(lc.row(1), &[3.0, 4.0]);
1621    }
1622
1623    /// `preserves_isometry_cross_block_coherence` must report exactly the
1624    /// charts whose Euclidean→Riemannian geometry transform is the identity on
1625    /// the per-row gradient / `H_tt` blocks. Keying the SAE isometry
1626    /// cross-block coupling decision on `is_euclidean()` instead of this
1627    /// predicate dropped the cross-block on the flat `Circle` chart, leaving a
1628    /// block-diagonal Hessian whose joint Newton step never reached KKT
1629    /// stationarity — the arrow-Schur proximal ridge then saturated at 1e15
1630    /// (issue #795, regression of #681). We pin the predicate AND its grounding
1631    /// invariant: on `Circle` the geometry transform really is the identity, so
1632    /// coherence is preserved; on `Sphere` / `Interval` it is not.
1633    #[test]
1634    fn isometry_cross_block_coherence_tracks_identity_geometry_transform() {
1635        assert!(LatentManifold::Euclidean.preserves_isometry_cross_block_coherence());
1636        assert!(
1637            LatentManifold::Circle {
1638                period: std::f64::consts::TAU
1639            }
1640            .preserves_isometry_cross_block_coherence()
1641        );
1642        assert!(!LatentManifold::Sphere { dim: 3 }.preserves_isometry_cross_block_coherence());
1643        assert!(
1644            !LatentManifold::Interval { lo: -1.0, hi: 1.0 }
1645                .preserves_isometry_cross_block_coherence()
1646        );
1647        // A Product is coherent iff every factor is.
1648        assert!(
1649            LatentManifold::Product(vec![
1650                LatentManifold::Euclidean,
1651                LatentManifold::Circle {
1652                    period: std::f64::consts::TAU
1653                },
1654            ])
1655            .preserves_isometry_cross_block_coherence()
1656        );
1657        assert!(
1658            !LatentManifold::Product(vec![
1659                LatentManifold::Circle {
1660                    period: std::f64::consts::TAU
1661                },
1662                LatentManifold::Sphere { dim: 3 },
1663            ])
1664            .preserves_isometry_cross_block_coherence()
1665        );
1666
1667        // Grounding invariant: on the Circle chart the geometry transform that
1668        // `apply_riemannian_latent_geometry` applies — gradient projection and
1669        // the Euclidean→Riemannian Hessian conversion — is the EXACT identity,
1670        // so the coupled `μ AᵀA` block survives intact and the cross-block must
1671        // be kept.
1672        let circle = LatentManifold::Circle {
1673            period: std::f64::consts::TAU,
1674        };
1675        let t = array![0.73_f64];
1676        let eg = array![2.4_f64];
1677        let eh = array![[1.7_f64]];
1678        let projected_g = circle.project_gradient_to_tangent(t.view(), eg.view());
1679        assert_eq!(
1680            projected_g, eg,
1681            "Circle gradient projection must be identity"
1682        );
1683        let rhess = circle.riemannian_hessian_matrix(t.view(), eg.view(), eh.view());
1684        assert_eq!(
1685            rhess, eh,
1686            "Circle Riemannian Hessian must equal the Euclidean Hessian"
1687        );
1688    }
1689
1690    /// `project_matrix_columns_to_tangent_into` (the hoisted, allocation-reuse
1691    /// projection used by the SAE arrow-Schur assembler) must match the
1692    /// per-column ground truth `project_to_tangent`, and must agree exactly
1693    /// with the allocating `project_matrix_columns_to_tangent` it backs, on a
1694    /// non-Euclidean (Sphere) manifold where the tangent projection is
1695    /// non-trivial. This pins the in-place projection introduced for the SAE
1696    /// hot-path scratch hoist.
1697    #[test]
1698    fn project_matrix_columns_to_tangent_into_matches_columnwise() {
1699        let manifold = LatentManifold::Sphere { dim: 3 };
1700        // Unit base point on S².
1701        let norm = (1.0_f64 + 4.0 + 4.0).sqrt();
1702        let t = array![1.0 / norm, 2.0 / norm, 2.0 / norm];
1703        let matrix = array![
1704            [0.3_f64, -1.1, 0.7, 2.0],
1705            [1.5, 0.2, -0.4, 0.9],
1706            [-0.6, 0.8, 1.3, -1.7],
1707        ];
1708        let mut into = Array2::<f64>::zeros(matrix.dim());
1709        manifold.project_matrix_columns_to_tangent_into(t.view(), matrix.view(), into.view_mut());
1710        let allocating = manifold.project_matrix_columns_to_tangent(t.view(), matrix.view());
1711        for col_idx in 0..matrix.ncols() {
1712            let expected = manifold.project_to_tangent(t.view(), matrix.column(col_idx));
1713            for row_idx in 0..matrix.nrows() {
1714                assert!(
1715                    (into[[row_idx, col_idx]] - expected[row_idx]).abs() < 1e-12,
1716                    "in-place projection deviates from columnwise truth at ({row_idx},{col_idx})"
1717                );
1718                assert_eq!(
1719                    into[[row_idx, col_idx]],
1720                    allocating[[row_idx, col_idx]],
1721                    "in-place and allocating projection differ at ({row_idx},{col_idx})"
1722                );
1723            }
1724        }
1725    }
1726
1727    /// Regression for #2295: a composite `Product` mixing a d=1 factor with a
1728    /// d=2 factor must split the flat gradient at the correct per-part offsets
1729    /// (each factor is projected at ITS OWN ambient width), round-trip with
1730    /// `offset == g.len()`, and reproduce every factor's standalone projection.
1731    /// The joint mixed-dimension superposition path (zoo dims=[1,1,2,2,2,2,2,1])
1732    /// drives exactly this split; a per-part width that ignored a factor's true
1733    /// dimension miscounted the offsets and tripped `assert_eq!(offset, g.len())`
1734    /// after the first sub-dimensional factor.
1735    #[test]
1736    fn product_gradient_projection_splits_mixed_dimensional_factors() {
1737        let circle = LatentManifold::Circle {
1738            period: std::f64::consts::TAU,
1739        };
1740        // `Sphere { dim: 2 }` is S¹ embedded in R², i.e. a genuinely 2-wide
1741        // ambient block whose tangent projection removes the radial component —
1742        // a non-trivial d=2 factor next to the flat d=1 circle.
1743        let sphere = LatentManifold::Sphere { dim: 2 };
1744        let product = LatentManifold::Product(vec![circle.clone(), sphere.clone()]);
1745
1746        // Ambient width = 1 (circle) + 2 (sphere) = 3, split at offsets 0 and 1.
1747        assert_eq!(product.ambient_dim(3), 3);
1748
1749        // Base point: the circle coordinate, then a unit 2-vector for the sphere.
1750        let t = array![0.5_f64, 0.6, 0.8];
1751        let g = array![1.3_f64, 2.0, -0.7];
1752
1753        let projected = product.project_gradient_to_tangent(t.view(), g.view());
1754        assert_eq!(
1755            projected.len(),
1756            3,
1757            "composite output tiles the full ambient"
1758        );
1759
1760        // Each factor projected standalone at its own offset/width must match the
1761        // composite's corresponding block.
1762        let circle_block = circle
1763            .project_gradient_to_tangent(t.slice(ndarray::s![0..1]), g.slice(ndarray::s![0..1]));
1764        let sphere_block = sphere
1765            .project_gradient_to_tangent(t.slice(ndarray::s![1..3]), g.slice(ndarray::s![1..3]));
1766        assert_eq!(projected[0], circle_block[0], "d=1 circle factor block");
1767        for a in 0..2 {
1768            assert_eq!(projected[1 + a], sphere_block[a], "d=2 sphere factor block");
1769        }
1770
1771        // Non-triviality guard: the sphere block genuinely removed the radial
1772        // component, so this is not a vacuous identity round-trip.
1773        let radial = g[1] * t[1] + g[2] * t[2];
1774        assert!(
1775            radial.abs() > 1e-6,
1776            "fixture must exercise a non-tangent gradient on the sphere factor"
1777        );
1778        assert!(
1779            (sphere_block[0] - (g[1] - radial * t[1])).abs() < 1e-12
1780                && (sphere_block[1] - (g[2] - radial * t[2])).abs() < 1e-12,
1781            "sphere tangent projection must remove the radial component"
1782        );
1783    }
1784
1785    /// Regression for issue #191 (and the K=2 periodic case of #174):
1786    /// `from_matrix_with_manifold(Circle)` must produce a value whose
1787    /// update path wraps into `[0, 2π)` even though the override
1788    /// `LatentRetractionRegistry` is left at its `all_euclidean` default.
1789    /// Before the fix, the retraction silently decayed to Euclidean and
1790    /// values drifted outside the circle on every Newton step.
1791    #[test]
1792    fn circle_manifold_update_wraps_into_canonical_interval() {
1793        let two_pi = std::f64::consts::TAU;
1794        let near_top = 6.2_f64;
1795        let m = array![[near_top]];
1796        let mut lc = LatentCoordValues::from_matrix_with_manifold(
1797            m.view(),
1798            LatentIdMode::None,
1799            LatentManifold::Circle { period: two_pi },
1800        );
1801        let delta = Array1::from(vec![1.5_f64]);
1802        lc.retract_flat_delta(delta.view());
1803        let updated = lc.row(0)[0];
1804        let expected = (near_top + 1.5).rem_euclid(two_pi);
1805        assert!(
1806            (0.0..two_pi).contains(&updated),
1807            "Circle retraction did not wrap into [0, 2π): got {updated}",
1808        );
1809        assert!(
1810            (updated - expected).abs() < 1e-12,
1811            "Circle retraction value mismatch: got {updated}, expected {expected}",
1812        );
1813
1814        let large_delta = Array1::from(vec![10.0 * two_pi + 0.25_f64]);
1815        lc.retract_flat_delta(large_delta.view());
1816        let after_big = lc.row(0)[0];
1817        assert!(
1818            (0.0..two_pi).contains(&after_big),
1819            "Circle retraction did not wrap a large delta: got {after_big}",
1820        );
1821    }
1822
1823    /// Mirror of the Circle regression for `LatentManifold::Sphere`: the
1824    /// per-row update must preserve unit norm. Before the fix the registry
1825    /// stayed Euclidean and the additive update broke the constraint.
1826    #[test]
1827    fn sphere_manifold_update_preserves_unit_norm() {
1828        let m = array![[1.0_f64, 0.0, 0.0]];
1829        let mut lc = LatentCoordValues::from_matrix_with_manifold(
1830            m.view(),
1831            LatentIdMode::None,
1832            LatentManifold::Sphere { dim: 3 },
1833        );
1834        let delta = Array1::from(vec![0.3_f64, 0.7, -0.2]);
1835        lc.retract_flat_delta(delta.view());
1836        let row = lc.row(0);
1837        let norm_sq: f64 = row.iter().map(|x| x * x).sum();
1838        assert!(
1839            (norm_sq.sqrt() - 1.0).abs() < 1e-12,
1840            "Sphere retraction did not preserve unit norm: ||t|| = {}",
1841            norm_sq.sqrt(),
1842        );
1843
1844        let big_delta = Array1::from(vec![50.0_f64, -25.0, 13.0]);
1845        lc.retract_flat_delta(big_delta.view());
1846        let row2 = lc.row(0);
1847        let norm_sq2: f64 = row2.iter().map(|x| x * x).sum();
1848        assert!(
1849            (norm_sq2.sqrt() - 1.0).abs() < 1e-12,
1850            "Sphere retraction failed to renormalize after large delta: ||t|| = {}",
1851            norm_sq2.sqrt(),
1852        );
1853    }
1854}