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
811 /// Validate the mode's identifiability composition (issue #912 step 2).
812 ///
813 /// `AuxOutcome` must carry a non-vacuous head (at least one labeled row)
814 /// and composes with ARD — a bare label channel with no axis-selection
815 /// under-pins the gauge. Returns the offending reason on failure so the
816 /// builder can reject before fitting. (The former `reject_dim_selection_alone`
817 /// guard was unified here into the Result path for a panic-free gate.)
818 pub fn validate(&self) -> Result<(), String> {
819 if matches!(self, Self::DimSelection { .. }) {
820 // `DimSelection` alone is rotation-symmetric — not a valid
821 // gauge fix; callers must pair ARD with `AuxPrior`/`Isometry`.
822 // Beautiful unification: return a proper error instead of a
823 // panic guard (removes the tracked ban stub while keeping the
824 // gate).
825 return Err("LatentIdMode::DimSelection is not a standalone gauge fix; \
826 pair ARD with AuxPrior or Isometry"
827 .to_string());
828 }
829 if let Self::AuxOutcome { head, .. } = self
830 && head.effective_labeled_count() <= 0.0
831 {
832 return Err(
833 "LatentIdMode::AuxOutcome: the behavioral head has no labeled rows \
834 (Σ row-weights = 0); a label-free head pins no gauge dimension. \
835 Provide labels or use AuxPrior/DimSelection composition."
836 .to_string(),
837 );
838 }
839 Ok(())
840 }
841}
842
843/// Carrier for the `∂Φ/∂t` chain-rule input, dispatched on basis kind by
844/// `LatentCoordValues::design_gradient_wrt_t_dispatch`.
845///
846/// * [`InputLocationDerivative::Radial`] is the *radial-kernel* path: the
847/// caller supplies the radial kernel family together with the center
848/// coordinates, and the chain rule
849/// `∂Φ/∂t = q(r) · (t − c)` is applied internally. This covers every
850/// isotropic radial basis — Duchon (any nullspace order), Matérn (every
851/// supported half-integer ν), and anything else whose pointwise
852/// gradient is radial. Helpers:
853/// [`crate::basis::duchon_radial_first_derivative_nd`],
854/// [`crate::basis::matern_radial_first_derivative_nd`].
855/// * [`InputLocationDerivative::Jet`] is the *pre-computed jet* path: the
856/// caller has already assembled a closed-form `(N, K, d)` tensor for a
857/// basis whose chain rule is not a simple radial scalar times a unit
858/// vector. Sphere kernels carry the tangent-direction times `K'(cos γ)`;
859/// periodic-cyclic B-splines carry the closed-form cardinal derivative;
860/// tensor-product B-splines carry the product-rule mix. Helpers:
861/// [`crate::basis::sphere_first_derivative_nd`],
862/// [`crate::basis::periodic_bspline_first_derivative_nd`],
863/// [`crate::basis::bspline_tensor_first_derivative`].
864///
865/// The dispatch is an enum rather than a trait because each path's
866/// arguments differ structurally (radial bases reuse scalar radial kernels shared with
867/// the kernel-shape chain machinery; jet bases ship the full tensor). All chain rules
868/// are analytic and closed-form; no autodiff, no finite differences.
869pub enum InputLocationDerivative<'a> {
870 /// Radial-kernel chain rule. The chain rule `(t − c)/r` is reconstructed
871 /// internally from the finite `q = φ'(r)/r` scalar and the center coordinates.
872 Radial {
873 centers: ArrayView2<'a, f64>,
874 radial_kind: &'a RadialScalarKind,
875 },
876 /// Pre-computed analytic `(n_obs, n_centers, latent_dim)` jet.
877 Jet(ArrayView3<'a, f64>),
878}
879
880/// Per-row latent coordinates `t ∈ ℝ^{N × d}` stored as a flat
881/// row-major `Array1<f64>` of length `n_obs * latent_dim`.
882///
883/// The flat-`Array1` layout mirrors [`crate::smooth::SpatialLogKappaCoords`]
884/// so the same `HyperDesignDerivative::from_implicit` / `DirectionalHyperParam`
885/// outer plumbing can consume it without modification.
886#[derive(Debug, Clone)]
887pub struct LatentCoordValues {
888 /// Stable process-local identity for this latent-coordinate block.
889 id: u64,
890 /// Flattened (n_obs, latent_dim) latent matrix, row-major
891 /// (so `values[n * d + k] = t_n[k]`).
892 values: Array1<f64>,
893 /// Number of rows `N`.
894 n_obs: usize,
895 /// Number of latent dimensions `d`.
896 latent_dim: usize,
897 /// Identifiability / gauge-fix mode.
898 id_mode: LatentIdMode,
899 /// Manifold used for per-row Riemannian updates.
900 manifold: LatentManifold,
901 /// Explicit update-side retraction. The empty registry is Euclidean.
902 retraction_registry: LatentRetractionRegistry,
903}
904
905impl LatentCoordValues {
906 /// Construct from a dense `(n_obs, latent_dim)` matrix.
907 pub fn from_matrix(matrix: ArrayView2<'_, f64>, id_mode: LatentIdMode) -> Self {
908 Self::from_matrix_with_manifold(matrix, id_mode, LatentManifold::Euclidean)
909 }
910
911 /// Construct from a dense matrix and explicit latent manifold.
912 pub fn from_matrix_with_manifold(
913 matrix: ArrayView2<'_, f64>,
914 id_mode: LatentIdMode,
915 manifold: LatentManifold,
916 ) -> Self {
917 Self::from_matrix_with_manifold_and_retraction(
918 matrix,
919 id_mode,
920 manifold,
921 LatentRetractionRegistry::all_euclidean(),
922 )
923 }
924
925 pub fn from_matrix_with_manifold_and_retraction(
926 matrix: ArrayView2<'_, f64>,
927 id_mode: LatentIdMode,
928 manifold: LatentManifold,
929 retraction_registry: LatentRetractionRegistry,
930 ) -> Self {
931 id_mode
932 .validate()
933 .expect("invalid LatentIdMode for LatentCoordValues::from_matrix_with_manifold");
934 let n_obs = matrix.nrows();
935 let latent_dim = matrix.ncols();
936 retraction_registry
937 .validate_dim(latent_dim, "LatentCoordValues::from_matrix_with_manifold")
938 .expect("invalid latent retraction dimension");
939 let mut values = Array1::<f64>::zeros(n_obs * latent_dim);
940 for n in 0..n_obs {
941 for k in 0..latent_dim {
942 values[n * latent_dim + k] = matrix[[n, k]];
943 }
944 }
945 let mut out = Self {
946 id: next_latent_coord_id(),
947 values,
948 n_obs,
949 latent_dim,
950 id_mode,
951 manifold,
952 retraction_registry,
953 };
954 out.project_all_rows_to_manifold();
955 out
956 }
957
958 /// Construct directly from a flat (`n_obs * latent_dim`) array.
959 pub fn from_flat(
960 values: Array1<f64>,
961 n_obs: usize,
962 latent_dim: usize,
963 id_mode: LatentIdMode,
964 ) -> Self {
965 Self::from_flat_with_manifold(
966 values,
967 n_obs,
968 latent_dim,
969 id_mode,
970 LatentManifold::Euclidean,
971 )
972 }
973
974 /// Construct directly from a flat array and explicit latent manifold.
975 pub fn from_flat_with_manifold(
976 values: Array1<f64>,
977 n_obs: usize,
978 latent_dim: usize,
979 id_mode: LatentIdMode,
980 manifold: LatentManifold,
981 ) -> Self {
982 Self::from_flat_with_manifold_and_retraction_and_id(
983 values,
984 n_obs,
985 latent_dim,
986 id_mode,
987 manifold,
988 LatentRetractionRegistry::all_euclidean(),
989 next_latent_coord_id(),
990 )
991 }
992
993 pub fn from_flat_with_manifold_and_retraction_and_id(
994 values: Array1<f64>,
995 n_obs: usize,
996 latent_dim: usize,
997 id_mode: LatentIdMode,
998 manifold: LatentManifold,
999 retraction_registry: LatentRetractionRegistry,
1000 id: u64,
1001 ) -> Self {
1002 id_mode
1003 .validate()
1004 .expect("invalid LatentIdMode for LatentCoordValues::from_flat");
1005 assert_eq!(
1006 values.len(),
1007 n_obs * latent_dim,
1008 "LatentCoordValues::from_flat: length {} != n_obs * latent_dim = {}",
1009 values.len(),
1010 n_obs * latent_dim
1011 );
1012 retraction_registry
1013 .validate_dim(latent_dim, "LatentCoordValues::from_flat_with_manifold")
1014 .expect("invalid latent retraction dimension");
1015 let mut out = Self {
1016 id,
1017 values,
1018 n_obs,
1019 latent_dim,
1020 id_mode,
1021 manifold,
1022 retraction_registry,
1023 };
1024 out.project_all_rows_to_manifold();
1025 out
1026 }
1027
1028 pub fn latent_id(&self) -> u64 {
1029 self.id
1030 }
1031
1032 pub fn n_obs(&self) -> usize {
1033 self.n_obs
1034 }
1035
1036 pub fn latent_dim(&self) -> usize {
1037 self.latent_dim
1038 }
1039
1040 /// Total length of the flat value array (= `n_obs * latent_dim`).
1041 pub fn len(&self) -> usize {
1042 self.values.len()
1043 }
1044
1045 pub fn is_empty(&self) -> bool {
1046 self.values.is_empty()
1047 }
1048
1049 pub fn id_mode(&self) -> &LatentIdMode {
1050 &self.id_mode
1051 }
1052
1053 pub fn manifold(&self) -> &LatentManifold {
1054 &self.manifold
1055 }
1056
1057 pub fn retraction_registry(&self) -> &LatentRetractionRegistry {
1058 &self.retraction_registry
1059 }
1060
1061 /// Effective "is all Euclidean" check used by the inner solver:
1062 /// returns `true` only when *both* the declared `LatentManifold` and the
1063 /// optional override retraction registry are Euclidean. The registry's
1064 /// own `is_all_euclidean` answers a strictly narrower question (was an
1065 /// explicit non-Euclidean override installed?) and would silently miss
1066 /// non-Euclidean manifolds installed via `from_matrix_with_manifold` /
1067 /// `with_manifold`, which left the registry at its `all_euclidean`
1068 /// default. See `retract_flat_delta` for the matching update path.
1069 pub fn effective_is_all_euclidean(&self) -> bool {
1070 self.manifold.is_euclidean() && self.retraction_registry.is_all_euclidean()
1071 }
1072
1073 /// Effective per-axis trust-region metric weights. When the manifold is
1074 /// non-Euclidean it is the authoritative geometric description (it
1075 /// covers `Interval` and `ProductWithMetric`, which the registry's
1076 /// `RetractionKind` cannot express), so we read weights from it. When
1077 /// the manifold is Euclidean but an explicit override retraction was
1078 /// supplied (e.g. via the JSON `retraction:` key) the registry's
1079 /// weights win.
1080 pub fn effective_metric_weights(&self) -> Vec<f64> {
1081 if self.manifold.is_euclidean() {
1082 self.retraction_registry.metric_weights(self.latent_dim)
1083 } else {
1084 self.manifold.metric_weights()
1085 }
1086 }
1087
1088 /// Effective per-axis periodicity (`Some(period)` on wrapped axes). When
1089 /// the declared manifold is non-Euclidean it is authoritative; when it is
1090 /// Euclidean, an explicit override retraction (if any) decides. Returns a
1091 /// `Vec` of length `latent_dim`.
1092 pub fn effective_axis_periods(&self) -> Vec<Option<f64>> {
1093 let periods = if self.manifold.is_euclidean() {
1094 self.retraction_registry.axis_periods(self.latent_dim)
1095 } else {
1096 self.manifold.axis_periods()
1097 };
1098 assert_eq!(
1099 periods.len(),
1100 self.latent_dim,
1101 "effective_axis_periods length {} != latent_dim {}",
1102 periods.len(),
1103 self.latent_dim
1104 );
1105 periods
1106 }
1107
1108 pub fn with_manifold(&self, manifold: LatentManifold) -> Self {
1109 Self::from_flat_with_manifold_and_retraction_and_id(
1110 self.values.clone(),
1111 self.n_obs,
1112 self.latent_dim,
1113 self.id_mode.clone(),
1114 manifold,
1115 self.retraction_registry.clone(),
1116 self.id,
1117 )
1118 }
1119
1120 /// View the flat value array.
1121 pub fn as_flat(&self) -> &Array1<f64> {
1122 &self.values
1123 }
1124
1125 /// View row `n` as a length-`d` slice.
1126 pub fn row(&self, n: usize) -> &[f64] {
1127 let start = n * self.latent_dim;
1128 let end = start + self.latent_dim;
1129 &self.values.as_slice().expect("contiguous")[start..end]
1130 }
1131
1132 /// Materialize as a dense `(n_obs, latent_dim)` matrix view.
1133 /// Useful when handing `t` to a row-major basis evaluator
1134 /// (e.g. `build_duchon_basis`).
1135 pub fn as_matrix(&self) -> Array2<f64> {
1136 let mut out = Array2::<f64>::zeros((self.n_obs, self.latent_dim));
1137 for n in 0..self.n_obs {
1138 for k in 0..self.latent_dim {
1139 out[[n, k]] = self.values[n * self.latent_dim + k];
1140 }
1141 }
1142 out
1143 }
1144
1145 /// Mutable write back of the flat value array, e.g. after a Newton step.
1146 pub fn set_flat(&mut self, flat: ArrayView1<'_, f64>) {
1147 assert_eq!(flat.len(), self.values.len());
1148 self.values.assign(&flat);
1149 self.project_all_rows_to_manifold();
1150 }
1151
1152 /// Apply a flat tangent update row-by-row through the manifold retraction.
1153 pub fn retract_flat_delta(&mut self, delta: ArrayView1<'_, f64>) {
1154 assert_eq!(delta.len(), self.values.len());
1155 if self.retraction_registry.is_all_euclidean() {
1156 if self.manifold.is_euclidean() {
1157 for (t, dt) in self.values.iter_mut().zip(delta.iter()) {
1158 *t += *dt;
1159 }
1160 return;
1161 }
1162 assert_eq!(
1163 self.manifold.ambient_dim(self.latent_dim),
1164 self.latent_dim,
1165 "LatentCoordValues::retract_flat_delta: manifold ambient dim does not match latent_dim",
1166 );
1167 for n in 0..self.n_obs {
1168 let start = n * self.latent_dim;
1169 let end = start + self.latent_dim;
1170 let next = self.manifold.retract(
1171 self.values.slice(ndarray::s![start..end]),
1172 delta.slice(ndarray::s![start..end]),
1173 );
1174 for a in 0..self.latent_dim {
1175 self.values[start + a] = next[a];
1176 }
1177 }
1178 return;
1179 }
1180 for n in 0..self.n_obs {
1181 let start = n * self.latent_dim;
1182 let end = start + self.latent_dim;
1183 let mut current = self.values.slice_mut(ndarray::s![start..end]);
1184 let xi = delta.slice(ndarray::s![start..end]);
1185 self.retraction_registry.retract(&mut current, xi);
1186 }
1187 }
1188
1189 fn project_all_rows_to_manifold(&mut self) {
1190 if self.manifold.is_euclidean() {
1191 return;
1192 }
1193 // In-place row projection writes back into the same `latent_dim`-wide
1194 // slice it read, so the manifold's ambient dimension must equal the
1195 // latent dimension. A mismatch means the slice arithmetic below would
1196 // read or write past a row boundary; say which two numbers disagreed
1197 // rather than only that they did.
1198 assert_eq!(
1199 self.manifold.ambient_dim(self.latent_dim),
1200 self.latent_dim,
1201 "in-place manifold projection requires ambient_dim == latent_dim: manifold reports \
1202 ambient {} for latent_dim {}",
1203 self.manifold.ambient_dim(self.latent_dim),
1204 self.latent_dim,
1205 );
1206 for n in 0..self.n_obs {
1207 let start = n * self.latent_dim;
1208 let end = start + self.latent_dim;
1209 let projected = self
1210 .manifold
1211 .project_point(self.values.slice(ndarray::s![start..end]));
1212 for a in 0..self.latent_dim {
1213 self.values[start + a] = projected[a];
1214 }
1215 }
1216 }
1217
1218 /// Apply this latent block back to a `TermCollectionSpec`-style covariate
1219 /// table: returns the `(N, d)` materialized matrix that downstream basis
1220 /// evaluators (Duchon, Matérn, ...) take as their feature input.
1221 ///
1222 /// This mirrors [`crate::smooth::SpatialLogKappaCoords::apply_tospec`],
1223 /// but the carrier on the spec side is the data-row covariate block rather
1224 /// than the per-term `length_scale`. The spec-mutation is handled at the
1225 /// call site (the consuming term needs to know which columns of its
1226 /// feature view to overwrite).
1227 pub fn apply_tospec(&self) -> Array2<f64> {
1228 self.as_matrix()
1229 }
1230
1231 /// Compute `∂Φ/∂t` for a radial-kernel design Φ — the original
1232 /// Duchon/Matérn path. See [`Self::design_gradient_wrt_t_dispatch`] for
1233 /// the basis-agnostic dispatch entry point.
1234 ///
1235 /// `centers` is `(n_centers, d)`.
1236 /// Returns a `(n_obs, n_centers, d)` jet whose `(n, k, a)` entry is
1237 /// `∂Φ_{n,k} / ∂t_{n,a} = q(r_{n,k}) · (t_{n,a} − c_{k,a})`.
1238 ///
1239 /// At `r = 0` the unit vector `(t − c)/r` is undefined; the radial scalar
1240 /// path therefore asks the kernel for the finite `q` limit and surfaces
1241 /// `BasisError::DegenerateAtCollision` when that limit does not exist.
1242 pub(crate) fn design_gradient_wrt_t(
1243 &self,
1244 centers: ArrayView2<'_, f64>,
1245 radial_kind: &RadialScalarKind,
1246 ) -> Result<Array3<f64>, BasisError> {
1247 let n_obs = self.n_obs;
1248 let d = self.latent_dim;
1249 let n_centers = centers.nrows();
1250 if centers.ncols() != d {
1251 crate::bail_dim_basis!(
1252 "LatentCoordValues::design_gradient_wrt_t center dimension mismatch: centers have {} cols but latent_dim is {}",
1253 centers.ncols(),
1254 d
1255 );
1256 }
1257 let mut jet = Array3::<f64>::zeros((n_obs, n_centers, d));
1258 for n in 0..n_obs {
1259 let t_n = self.row(n);
1260 for k in 0..n_centers {
1261 let mut r2 = 0.0_f64;
1262 for a in 0..d {
1263 let delta = t_n[a] - centers[[k, a]];
1264 r2 += delta * delta;
1265 }
1266 let r = r2.sqrt();
1267 let (_, q, _) = radial_kind.eval_design_triplet(r)?;
1268 if q == 0.0 {
1269 continue;
1270 }
1271 for a in 0..d {
1272 jet[[n, k, a]] = q * (t_n[a] - centers[[k, a]]);
1273 }
1274 }
1275 }
1276 Ok(jet)
1277 }
1278
1279 /// Compute `∂Φ/∂t` for an arbitrary supported basis kind, by dispatching
1280 /// to the right closed-form chain rule.
1281 ///
1282 /// All radial-kernel bases (Duchon, Matérn) reduce to the same
1283 /// `q(r) · (t − c)` chain that `design_gradient_wrt_t` already implements.
1284 /// Non-radial bases (sphere, periodic-cyclic B-spline, tensor
1285 /// B-spline) carry their own analytic `(N, K, d)` jet — the caller
1286 /// pre-builds that jet using the matching `*_first_derivative_nd` helper
1287 /// in [`crate::basis`] and passes it in via
1288 /// [`InputLocationDerivative::Jet`].
1289 ///
1290 /// This is the single entry point the outer optimizer should call; it
1291 /// stays in lock-step with the kernel-parameter chain rule that
1292 /// `SpatialLogKappaCoords` uses (re-pointed at the first kernel argument
1293 /// rather than at kernel anisotropy).
1294 pub(crate) fn design_gradient_wrt_t_dispatch(
1295 &self,
1296 input: InputLocationDerivative<'_>,
1297 ) -> Result<Array3<f64>, BasisError> {
1298 match input {
1299 InputLocationDerivative::Radial {
1300 centers,
1301 radial_kind,
1302 } => self.design_gradient_wrt_t(centers, radial_kind),
1303 InputLocationDerivative::Jet(jet) => {
1304 if jet.shape() != [self.n_obs, jet.shape()[1], self.latent_dim] {
1305 crate::bail_dim_basis!(
1306 "LatentCoordValues::design_gradient_wrt_t_dispatch jet shape {:?} does not match latent shape ({}, {}, {})",
1307 jet.shape(),
1308 self.n_obs,
1309 jet.shape()[1],
1310 self.latent_dim
1311 );
1312 }
1313 // The non-radial helpers already produce a (N, K, d) tensor
1314 // in the layout downstream contraction consumes. Return a copy
1315 // so the caller owns the data and is decoupled from the source
1316 // array's lifetime.
1317 Ok(jet.to_owned())
1318 }
1319 }
1320 }
1321}
1322
1323
1324fn wrap_to_period(x: f64, period: f64) -> f64 {
1325 assert!(
1326 period.is_finite() && period > 0.0,
1327 "wrap_to_period requires a finite positive period; got {period}"
1328 );
1329 let y = x.rem_euclid(period);
1330 if y == period { 0.0 } else { y }
1331}
1332
1333/// Normalize `v[0..dim]` to a unit vector (for `LatentManifold::Sphere`
1334/// projection and retraction).
1335///
1336/// "Or axis": if the input is zero or non-finite (degenerate or numerical
1337/// mishap in caller), gracefully fall back to the canonical first axis
1338/// unit vector `[1, 0, …, 0]`. This removes a hard panic while preserving
1339/// the sphere contract that every returned point has unit Euclidean norm.
1340/// Callers (project_point / retract on Sphere) already ensure dim matches
1341/// the view length for the manifold component.
1342fn normalize_or_axis(v: ArrayView1<'_, f64>, dim: usize) -> Array1<f64> {
1343 let mut norm_sq = 0.0_f64;
1344 for a in 0..dim {
1345 norm_sq += v[a] * v[a];
1346 }
1347 const EPS: f64 = 1e-300; // protect against underflow/denorm that would give Inf
1348 if norm_sq > EPS && norm_sq.is_finite() {
1349 let inv = 1.0 / norm_sq.sqrt();
1350 let mut out = Array1::<f64>::zeros(dim);
1351 for a in 0..dim {
1352 out[a] = v[a] * inv;
1353 }
1354 out
1355 } else {
1356 // "or axis" fallback — beautiful, non-panicking resolution for
1357 // degenerate ambient vector on the sphere.
1358 let mut out = Array1::<f64>::zeros(dim);
1359 if dim > 0 {
1360 out[0] = 1.0;
1361 }
1362 out
1363 }
1364}
1365
1366#[inline]
1367fn symmetrize(a: &mut Array2<f64>) {
1368 // Callers in this module always pass square (d, d) matrices; delegate to
1369 // the canonical helper in `linalg::utils`.
1370 gam_linalg::matrix::symmetrize_in_place(a)
1371}
1372
1373/// Auxiliary-prior penalty contribution: returns the per-row reference
1374/// coordinates `ĥ(u_n)` shape `(n_obs, d)` and the effective strength `μ`.
1375///
1376/// `t_target` is broadcast across the inner ridge of `½ μ · ‖t − t_target‖²`,
1377/// which the call site folds into the Y-stack via a virtual-row augmentation
1378/// (`y' = [y; √μ · t_target]`, `X' = [X; √μ · I_d ⊗ row-block]`). This
1379/// keeps the inner solver Gaussian-closed-form.
1380///
1381/// For `AuxPriorFamily::Ridge` the conditional mean is the closed-form ridge
1382/// regression `(UᵀU + ε I)⁻¹ UᵀT` evaluated at each row's `u_n`. For
1383/// `Linear` the ridge is zero (which raises if `UᵀU` is singular).
1384/// Closed-form auxiliary-prior REML statistics at a fixed outer coordinate `t`.
1385pub struct AuxPriorRemlStats {
1386 pub residual_sq: f64,
1387 pub log_mu: f64,
1388 pub mu: f64,
1389 pub auto: bool,
1390 pub score: f64,
1391}
1392
1393/// Auxiliary-prior REML statistics for a fixed outer coordinate `t`, given the
1394/// precomputed `targets` (see [`aux_prior_targets`]). Returns the residual sum of
1395/// squares, the precision `mu` (the supplied `aux_strength` when `Some`, else the
1396/// closed-form REML optimum `mu = K / Σr²`), whether it was auto-selected, and
1397/// the prior score `0.5·mu·Σr² − 0.5·K·ln(mu)`. The `log_mu` coordinate has this
1398/// closed-form optimum at fixed `t` because only the normalized auxiliary prior
1399/// depends on it.
1400///
1401/// `K = n_obs · latent_dim` is the number of scalar latent coordinates the single
1402/// shared precision `mu` governs. The normalizer term `−0.5·K·ln(mu)` is the prior
1403/// log-determinant `−0.5·log det₊(mu · I_K)`, so it counts every governed
1404/// coordinate. Counting only `n_obs` undercounts a `latent_dim`-dimensional latent
1405/// by exactly `latent_dim`, which biases the REML precision toward under-shrinkage
1406/// (the per-axis ARD path emits `−0.5·n_obs·ln(α)` for each of `latent_dim` axes;
1407/// a single shared `mu` must match that sum).
1408pub fn aux_prior_reml_stats(
1409 t_mat: ArrayView2<'_, f64>,
1410 targets: ArrayView2<'_, f64>,
1411 aux_strength: Option<f64>,
1412) -> Result<AuxPriorRemlStats, String> {
1413 let n_obs = t_mat.nrows();
1414 let latent_dim = t_mat.ncols();
1415 let mut residual_sq = 0.0_f64;
1416 for n in 0..n_obs {
1417 for a in 0..latent_dim {
1418 let diff = t_mat[[n, a]] - targets[[n, a]];
1419 residual_sq += diff * diff;
1420 }
1421 }
1422 if !residual_sq.is_finite() {
1423 return Err("auxiliary prior residual norm must be finite".to_string());
1424 }
1425 let (log_mu, mu, auto) = match aux_strength {
1426 Some(mu) => {
1427 if !(mu.is_finite() && mu > 0.0) {
1428 return Err(format!(
1429 "aux_strength must be finite and positive; got {mu}"
1430 ));
1431 }
1432 (mu.ln(), mu, false)
1433 }
1434 None => {
1435 if residual_sq <= 0.0 {
1436 return Err(
1437 "aux_strength='auto' has no finite REML optimum when the auxiliary residual is zero"
1438 .to_string(),
1439 );
1440 }
1441 let mu = ((n_obs * latent_dim) as f64) / residual_sq;
1442 if !(mu.is_finite() && mu > 0.0) {
1443 return Err(format!(
1444 "auto aux_strength selected a non-finite precision: {mu}"
1445 ));
1446 }
1447 (mu.ln(), mu, true)
1448 }
1449 };
1450 let score = 0.5 * mu * residual_sq - 0.5 * ((n_obs * latent_dim) as f64) * log_mu;
1451 Ok(AuxPriorRemlStats {
1452 residual_sq,
1453 log_mu,
1454 mu,
1455 auto,
1456 score,
1457 })
1458}
1459
1460pub fn aux_prior_targets(
1461 t: ArrayView2<'_, f64>,
1462 u: ArrayView2<'_, f64>,
1463 family: AuxPriorFamily,
1464) -> Result<Array2<f64>, String> {
1465 let n_obs = t.nrows();
1466 let d = t.ncols();
1467 if u.nrows() != n_obs {
1468 return Err(format!(
1469 "aux_prior_targets: u has {} rows but t has {}",
1470 u.nrows(),
1471 n_obs
1472 ));
1473 }
1474 let p = u.ncols();
1475 if p == 0 {
1476 return Err("aux_prior_targets: auxiliary u must have at least one column".into());
1477 }
1478 // gram = UᵀU (p × p)
1479 let mut gram = Array2::<f64>::zeros((p, p));
1480 for n in 0..n_obs {
1481 for i in 0..p {
1482 for j in 0..p {
1483 gram[[i, j]] += u[[n, i]] * u[[n, j]];
1484 }
1485 }
1486 }
1487 let ridge_eps = match family {
1488 AuxPriorFamily::Ridge => {
1489 let trace: f64 = (0..p).map(|i| gram[[i, i]]).sum();
1490 (1e-6 * trace / p as f64).max(1e-12)
1491 }
1492 AuxPriorFamily::Linear => 0.0,
1493 };
1494 for i in 0..p {
1495 gram[[i, i]] += ridge_eps;
1496 }
1497 // rhs = UᵀT (p × d)
1498 let mut rhs = Array2::<f64>::zeros((p, d));
1499 for n in 0..n_obs {
1500 for i in 0..p {
1501 for k in 0..d {
1502 rhs[[i, k]] += u[[n, i]] * t[[n, k]];
1503 }
1504 }
1505 }
1506 let coeffs = solve_spd(gram.view(), rhs.view())?;
1507 // targets = U · coeffs (n_obs × d)
1508 let mut targets = Array2::<f64>::zeros((n_obs, d));
1509 for n in 0..n_obs {
1510 for k in 0..d {
1511 let mut acc = 0.0_f64;
1512 for i in 0..p {
1513 acc += u[[n, i]] * coeffs[[i, k]];
1514 }
1515 targets[[n, k]] = acc;
1516 }
1517 }
1518 Ok(targets)
1519}
1520
1521/// Lightweight Cholesky-based SPD solve. Keeps this module dependency-free
1522/// from the broader faer-wrapping surface; matrices here are tiny
1523/// (`p × p` with p = aux-feature count, typically O(10)).
1524fn solve_spd(a: ArrayView2<'_, f64>, b: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
1525 let n = a.nrows();
1526 if a.ncols() != n {
1527 return Err("solve_spd: A must be square".into());
1528 }
1529 if b.nrows() != n {
1530 return Err("solve_spd: RHS row count mismatch".into());
1531 }
1532 // In-place Cholesky factorization. We pay the O(n³) copy + O(n³) factor
1533 // up front; n is tiny in the auxiliary-prior path.
1534 let mut l = Array2::<f64>::zeros((n, n));
1535 for i in 0..n {
1536 for j in 0..=i {
1537 let mut sum = a[[i, j]];
1538 for k in 0..j {
1539 sum -= l[[i, k]] * l[[j, k]];
1540 }
1541 if i == j {
1542 if sum <= 0.0 {
1543 return Err(format!(
1544 "solve_spd: non-positive pivot {sum} at index {i} \
1545 (matrix is not positive definite)"
1546 ));
1547 }
1548 l[[i, j]] = sum.sqrt();
1549 } else {
1550 l[[i, j]] = sum / l[[j, j]];
1551 }
1552 }
1553 }
1554 // Solve L y = b, then Lᵀ x = y, column by column.
1555 let d = b.ncols();
1556 let mut out = Array2::<f64>::zeros((n, d));
1557 for col in 0..d {
1558 let mut y = Array1::<f64>::zeros(n);
1559 for i in 0..n {
1560 let mut sum = b[[i, col]];
1561 for k in 0..i {
1562 sum -= l[[i, k]] * y[k];
1563 }
1564 y[i] = sum / l[[i, i]];
1565 }
1566 for i in (0..n).rev() {
1567 let mut sum = y[i];
1568 for k in (i + 1)..n {
1569 sum -= l[[k, i]] * out[[k, col]];
1570 }
1571 out[[i, col]] = sum / l[[i, i]];
1572 }
1573 }
1574 Ok(out)
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580 use ndarray::array;
1581
1582 #[test]
1583 fn from_matrix_roundtrip() {
1584 let m = array![[1.0_f64, 2.0], [3.0, 4.0], [5.0, 6.0]];
1585 let lc = LatentCoordValues::from_matrix(m.view(), LatentIdMode::None);
1586 assert_eq!(lc.n_obs(), 3);
1587 assert_eq!(lc.latent_dim(), 2);
1588 let back = lc.as_matrix();
1589 assert_eq!(back, m);
1590 }
1591
1592 #[test]
1593 fn row_access() {
1594 let m = array![[1.0_f64, 2.0], [3.0, 4.0]];
1595 let lc = LatentCoordValues::from_matrix(m.view(), LatentIdMode::None);
1596 assert_eq!(lc.row(0), &[1.0, 2.0]);
1597 assert_eq!(lc.row(1), &[3.0, 4.0]);
1598 }
1599
1600 /// `preserves_isometry_cross_block_coherence` must report exactly the
1601 /// charts whose Euclidean→Riemannian geometry transform is the identity on
1602 /// the per-row gradient / `H_tt` blocks. Keying the SAE isometry
1603 /// cross-block coupling decision on `is_euclidean()` instead of this
1604 /// predicate dropped the cross-block on the flat `Circle` chart, leaving a
1605 /// block-diagonal Hessian whose joint Newton step never reached KKT
1606 /// stationarity — the arrow-Schur proximal ridge then saturated at 1e15
1607 /// (issue #795, regression of #681). We pin the predicate AND its grounding
1608 /// invariant: on `Circle` the geometry transform really is the identity, so
1609 /// coherence is preserved; on `Sphere` / `Interval` it is not.
1610 #[test]
1611 fn isometry_cross_block_coherence_tracks_identity_geometry_transform() {
1612 assert!(LatentManifold::Euclidean.preserves_isometry_cross_block_coherence());
1613 assert!(
1614 LatentManifold::Circle {
1615 period: std::f64::consts::TAU
1616 }
1617 .preserves_isometry_cross_block_coherence()
1618 );
1619 assert!(!LatentManifold::Sphere { dim: 3 }.preserves_isometry_cross_block_coherence());
1620 assert!(
1621 !LatentManifold::Interval { lo: -1.0, hi: 1.0 }
1622 .preserves_isometry_cross_block_coherence()
1623 );
1624 // A Product is coherent iff every factor is.
1625 assert!(
1626 LatentManifold::Product(vec![
1627 LatentManifold::Euclidean,
1628 LatentManifold::Circle {
1629 period: std::f64::consts::TAU
1630 },
1631 ])
1632 .preserves_isometry_cross_block_coherence()
1633 );
1634 assert!(
1635 !LatentManifold::Product(vec![
1636 LatentManifold::Circle {
1637 period: std::f64::consts::TAU
1638 },
1639 LatentManifold::Sphere { dim: 3 },
1640 ])
1641 .preserves_isometry_cross_block_coherence()
1642 );
1643
1644 // Grounding invariant: on the Circle chart the geometry transform that
1645 // `apply_riemannian_latent_geometry` applies — gradient projection and
1646 // the Euclidean→Riemannian Hessian conversion — is the EXACT identity,
1647 // so the coupled `μ AᵀA` block survives intact and the cross-block must
1648 // be kept.
1649 let circle = LatentManifold::Circle {
1650 period: std::f64::consts::TAU,
1651 };
1652 let t = array![0.73_f64];
1653 let eg = array![2.4_f64];
1654 let eh = array![[1.7_f64]];
1655 let projected_g = circle.project_gradient_to_tangent(t.view(), eg.view());
1656 assert_eq!(
1657 projected_g, eg,
1658 "Circle gradient projection must be identity"
1659 );
1660 let rhess = circle.riemannian_hessian_matrix(t.view(), eg.view(), eh.view());
1661 assert_eq!(
1662 rhess, eh,
1663 "Circle Riemannian Hessian must equal the Euclidean Hessian"
1664 );
1665 }
1666
1667 /// `project_matrix_columns_to_tangent_into` (the hoisted, allocation-reuse
1668 /// projection used by the SAE arrow-Schur assembler) must match the
1669 /// per-column ground truth `project_to_tangent`, and must agree exactly
1670 /// with the allocating `project_matrix_columns_to_tangent` it backs, on a
1671 /// non-Euclidean (Sphere) manifold where the tangent projection is
1672 /// non-trivial. This pins the in-place projection introduced for the SAE
1673 /// hot-path scratch hoist.
1674 #[test]
1675 fn project_matrix_columns_to_tangent_into_matches_columnwise() {
1676 let manifold = LatentManifold::Sphere { dim: 3 };
1677 // Unit base point on S².
1678 let norm = (1.0_f64 + 4.0 + 4.0).sqrt();
1679 let t = array![1.0 / norm, 2.0 / norm, 2.0 / norm];
1680 let matrix = array![
1681 [0.3_f64, -1.1, 0.7, 2.0],
1682 [1.5, 0.2, -0.4, 0.9],
1683 [-0.6, 0.8, 1.3, -1.7],
1684 ];
1685 let mut into = Array2::<f64>::zeros(matrix.dim());
1686 manifold.project_matrix_columns_to_tangent_into(t.view(), matrix.view(), into.view_mut());
1687 let allocating = manifold.project_matrix_columns_to_tangent(t.view(), matrix.view());
1688 for col_idx in 0..matrix.ncols() {
1689 let expected = manifold.project_to_tangent(t.view(), matrix.column(col_idx));
1690 for row_idx in 0..matrix.nrows() {
1691 assert!(
1692 (into[[row_idx, col_idx]] - expected[row_idx]).abs() < 1e-12,
1693 "in-place projection deviates from columnwise truth at ({row_idx},{col_idx})"
1694 );
1695 assert_eq!(
1696 into[[row_idx, col_idx]],
1697 allocating[[row_idx, col_idx]],
1698 "in-place and allocating projection differ at ({row_idx},{col_idx})"
1699 );
1700 }
1701 }
1702 }
1703
1704 /// Regression for #2295: a composite `Product` mixing a d=1 factor with a
1705 /// d=2 factor must split the flat gradient at the correct per-part offsets
1706 /// (each factor is projected at ITS OWN ambient width), round-trip with
1707 /// `offset == g.len()`, and reproduce every factor's standalone projection.
1708 /// The joint mixed-dimension superposition path (zoo dims=[1,1,2,2,2,2,2,1])
1709 /// drives exactly this split; a per-part width that ignored a factor's true
1710 /// dimension miscounted the offsets and tripped `assert_eq!(offset, g.len())`
1711 /// after the first sub-dimensional factor.
1712 #[test]
1713 fn product_gradient_projection_splits_mixed_dimensional_factors() {
1714 let circle = LatentManifold::Circle {
1715 period: std::f64::consts::TAU,
1716 };
1717 // `Sphere { dim: 2 }` is S¹ embedded in R², i.e. a genuinely 2-wide
1718 // ambient block whose tangent projection removes the radial component —
1719 // a non-trivial d=2 factor next to the flat d=1 circle.
1720 let sphere = LatentManifold::Sphere { dim: 2 };
1721 let product = LatentManifold::Product(vec![circle.clone(), sphere.clone()]);
1722
1723 // Ambient width = 1 (circle) + 2 (sphere) = 3, split at offsets 0 and 1.
1724 assert_eq!(product.ambient_dim(3), 3);
1725
1726 // Base point: the circle coordinate, then a unit 2-vector for the sphere.
1727 let t = array![0.5_f64, 0.6, 0.8];
1728 let g = array![1.3_f64, 2.0, -0.7];
1729
1730 let projected = product.project_gradient_to_tangent(t.view(), g.view());
1731 assert_eq!(
1732 projected.len(),
1733 3,
1734 "composite output tiles the full ambient"
1735 );
1736
1737 // Each factor projected standalone at its own offset/width must match the
1738 // composite's corresponding block.
1739 let circle_block = circle
1740 .project_gradient_to_tangent(t.slice(ndarray::s![0..1]), g.slice(ndarray::s![0..1]));
1741 let sphere_block = sphere
1742 .project_gradient_to_tangent(t.slice(ndarray::s![1..3]), g.slice(ndarray::s![1..3]));
1743 assert_eq!(projected[0], circle_block[0], "d=1 circle factor block");
1744 for a in 0..2 {
1745 assert_eq!(projected[1 + a], sphere_block[a], "d=2 sphere factor block");
1746 }
1747
1748 // Non-triviality guard: the sphere block genuinely removed the radial
1749 // component, so this is not a vacuous identity round-trip.
1750 let radial = g[1] * t[1] + g[2] * t[2];
1751 assert!(
1752 radial.abs() > 1e-6,
1753 "fixture must exercise a non-tangent gradient on the sphere factor"
1754 );
1755 assert!(
1756 (sphere_block[0] - (g[1] - radial * t[1])).abs() < 1e-12
1757 && (sphere_block[1] - (g[2] - radial * t[2])).abs() < 1e-12,
1758 "sphere tangent projection must remove the radial component"
1759 );
1760 }
1761
1762 /// Regression for issue #191 (and the K=2 periodic case of #174):
1763 /// `from_matrix_with_manifold(Circle)` must produce a value whose
1764 /// update path wraps into `[0, 2π)` even though the override
1765 /// `LatentRetractionRegistry` is left at its `all_euclidean` default.
1766 /// Before the fix, the retraction silently decayed to Euclidean and
1767 /// values drifted outside the circle on every Newton step.
1768 #[test]
1769 fn circle_manifold_update_wraps_into_canonical_interval() {
1770 let two_pi = std::f64::consts::TAU;
1771 let near_top = 6.2_f64;
1772 let m = array![[near_top]];
1773 let mut lc = LatentCoordValues::from_matrix_with_manifold(
1774 m.view(),
1775 LatentIdMode::None,
1776 LatentManifold::Circle { period: two_pi },
1777 );
1778 let delta = Array1::from(vec![1.5_f64]);
1779 lc.retract_flat_delta(delta.view());
1780 let updated = lc.row(0)[0];
1781 let expected = (near_top + 1.5).rem_euclid(two_pi);
1782 assert!(
1783 (0.0..two_pi).contains(&updated),
1784 "Circle retraction did not wrap into [0, 2π): got {updated}",
1785 );
1786 assert!(
1787 (updated - expected).abs() < 1e-12,
1788 "Circle retraction value mismatch: got {updated}, expected {expected}",
1789 );
1790
1791 let large_delta = Array1::from(vec![10.0 * two_pi + 0.25_f64]);
1792 lc.retract_flat_delta(large_delta.view());
1793 let after_big = lc.row(0)[0];
1794 assert!(
1795 (0.0..two_pi).contains(&after_big),
1796 "Circle retraction did not wrap a large delta: got {after_big}",
1797 );
1798 }
1799
1800 /// Mirror of the Circle regression for `LatentManifold::Sphere`: the
1801 /// per-row update must preserve unit norm. Before the fix the registry
1802 /// stayed Euclidean and the additive update broke the constraint.
1803 #[test]
1804 fn sphere_manifold_update_preserves_unit_norm() {
1805 let m = array![[1.0_f64, 0.0, 0.0]];
1806 let mut lc = LatentCoordValues::from_matrix_with_manifold(
1807 m.view(),
1808 LatentIdMode::None,
1809 LatentManifold::Sphere { dim: 3 },
1810 );
1811 let delta = Array1::from(vec![0.3_f64, 0.7, -0.2]);
1812 lc.retract_flat_delta(delta.view());
1813 let row = lc.row(0);
1814 let norm_sq: f64 = row.iter().map(|x| x * x).sum();
1815 assert!(
1816 (norm_sq.sqrt() - 1.0).abs() < 1e-12,
1817 "Sphere retraction did not preserve unit norm: ||t|| = {}",
1818 norm_sq.sqrt(),
1819 );
1820
1821 let big_delta = Array1::from(vec![50.0_f64, -25.0, 13.0]);
1822 lc.retract_flat_delta(big_delta.view());
1823 let row2 = lc.row(0);
1824 let norm_sq2: f64 = row2.iter().map(|x| x * x).sum();
1825 assert!(
1826 (norm_sq2.sqrt() - 1.0).abs() < 1e-12,
1827 "Sphere retraction failed to renormalize after large delta: ||t|| = {}",
1828 norm_sq2.sqrt(),
1829 );
1830 }
1831}