gam_terms/analytic_penalties/isometry.rs
1use super::*;
2pub use gam_problem::WeightField;
3
4// ---------------------------------------------------------------------------
5// Isometry penalty
6// ---------------------------------------------------------------------------
7
8/// Choice of reference Riemannian metric `g^ref(t)` on the latent manifold.
9///
10/// `Euclidean` is the natural default: the reference metric is `I_d`, so the
11/// penalty pulls the decoder toward locally-isometric (length-preserving)
12/// behavior. `UserSupplied` lets the caller hand in a `(n_obs, d, d)` jet of
13/// per-row reference metrics (useful for warm-starting from a chart of a
14/// pre-fit GP-LVM).
15#[derive(Clone)]
16pub enum IsometryReference {
17 Euclidean,
18 UserSupplied(Arc<Array2<f64>>), // (n_obs, d*d) row-major flattened
19}
20
21impl std::fmt::Debug for IsometryReference {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 IsometryReference::Euclidean => f.write_str("Euclidean"),
25 IsometryReference::UserSupplied(a) => f
26 .debug_tuple("UserSupplied")
27 .field(&format_args!("{}×{}", a.nrows(), a.ncols()))
28 .finish(),
29 }
30 }
31}
32
33/// Radial Duchon decoder metadata used to materialize
34/// `∂J_n[i, a] / ∂t_{n, c}` from `φ'(r)` and `φ''(r)` on demand.
35///
36/// `radial_coefficients[k, i]` is the decoder coefficient that maps radial
37/// basis column `k` into output channel `i`. Polynomial-tail columns are not
38/// represented here; callers whose decoder contains a non-linear polynomial
39/// tail should provide `jacobian_second_cache` directly.
40#[derive(Debug, Clone)]
41pub struct IsometryDuchonRadialSource {
42 pub centers: Arc<Array2<f64>>,
43 pub radial_coefficients: Arc<Array2<f64>>,
44 pub length_scale: Option<f64>,
45 pub nullspace_order: DuchonNullspaceOrder,
46 /// Forward hybrid spectral order `s = spec.power`. The Cartesian
47 /// derivative engine must resolve the same `(p, s, κ)` the forward
48 /// `build_duchon_basis` used, so it differentiates the exact resolved
49 /// hybrid Green's function `φ_{p,s,κ}` rather than a hard-coded `s = 0`
50 /// surrogate (issue #440).
51 pub power: usize,
52}
53
54/// Isometry-to-reference penalty (canonical-coordinate gauge term).
55///
56/// Lives on ext-coords: the target slice is a row of the `LatentCoordValues` flat
57/// vector (row-major `n_obs × d`). Owns one ρ-axis (`log μ_iso`).
58///
59/// Penalizes `½ μ Σ_n ‖g_n(t) − g^ref(t_n)‖²_F`, where the pullback metric
60/// at row `n` is
61///
62/// ```text
63/// g_n = J_n^T W_n J_n, J_n ∈ ℝ^{p × d}
64/// ```
65///
66/// and `W_n` is a per-row low-rank PSD behavioral metric stored as
67/// `W_n = U_n U_n^T` with `U_n ∈ ℝ^{p × r}`. The canonical-coordinate
68/// statement is "one unit of motion in `t` ↦ one unit of behavioral change",
69/// so the `W_n` weighting is load-bearing.
70///
71/// In the SAE objective this is the extension-coordinate gauge fix: it prevents
72/// the latent chart from absorbing arbitrary smooth reparameterizations of the
73/// decoder manifold. ARD, sparsity, or rank penalties can then select axes or
74/// structure in a chart whose metric scale is pinned.
75///
76/// **Contraction order invariant.** Every place this struct touches `W_n`,
77/// the contraction is `(J^T U_n)(U_n^T J)` — never `J^T W_n J` with `W_n`
78/// materialized as `p × p`. Concretely we form `M_n = U_n^T J_n ∈ ℝ^{r × d}`
79/// once and then `g_n = M_n^T M_n` (`d × d`). Cost per row:
80/// `O(p · r · d + r · d²)`, independent of `p²`.
81///
82/// **When to use.** Whenever a `LatentCoord` block is in play without an
83/// auxiliary variable (`AuxPrior`) to break the diffeomorphism gauge. Fixes
84/// the audit finding that ARD is not a standalone gauge fix. With a Euclidean
85/// reference, the penalty pulls the decoder toward a local isometry, which is
86/// enough to make the inner Hessian on `t` full-rank and the IFT well-defined.
87///
88/// **Math.** Let `J_n ∈ ℝ^{p × d}` be the local decoder Jacobian. Then
89/// `g_n = J_n^T W_n J_n` and the penalty is
90/// `½ μ Σ_n ‖J_n^T W_n J_n − g^ref_n‖²_F`. Analytic gradient w.r.t. `t_n`:
91///
92/// ```text
93/// ∂P/∂t_{n,c}
94/// = μ Σ_{a,b} (g_n − g^ref_n)_{ab}
95/// [ H_{n,:,a,c}^T W_n J_{n,:,b}
96/// + J_{n,:,a}^T W_n H_{n,:,b,c} ],
97/// H_{n,i,a,c} = ∂J_{n,i,a}/∂t_{n,c}.
98/// ```
99///
100/// Gotchas:
101///
102/// * The value path returns the configured missing-cache default when the
103/// first-jet cache is absent; gradient/HVP paths need the first and second
104/// decoder jets and return zeros when the analytic jet source is unavailable.
105/// * The exact Hessian includes a residual-curvature term requiring the third
106/// decoder jet. REML/PIRLS curvature should prefer the Gauss-Newton PSD
107/// majorizer when a positive curvature block is required.
108/// * `W_n` is a metric weight, not a scalar confidence. Changing it changes the
109/// canonical units of latent motion.
110///
111/// The per-row Jacobian `J_n` is exactly the radial-derivative jet
112/// `design_gradient_wrt_t` already computes for `LatentCoordValues`; the
113/// second derivative `∂J/∂t` is built by the shared
114/// [`crate::basis::radial_basis_cartesian_derivative`] engine from the
115/// radial Hessian identity. A finite-difference oracle for the docstring is
116/// to central-difference `value(t ± h e_j)` against `grad_target(t)[j]`;
117/// the analytic value follows the oracle until finite-difference
118/// cancellation dominates. No autograd needed.
119///
120/// `μ = exp(ρ_iso)` is REML-selectable as one extra ρ axis.
121///
122/// `jacobian_cache_slot` and `jacobian_second_cache_slot` are interior-mutable
123/// (`RwLock<Option<Arc<…>>>`) so the SAE outer loop can refresh them in place
124/// each step without needing `&mut self` on the registry-held penalty (see
125/// `refresh_caches` and [`crate::terms::sae::manifold::refresh_isometry_caches_from_atom`]).
126/// Readers go through the [`Self::jacobian_cache`] / [`Self::jacobian_second_cache`]
127/// accessors, which take the read lock briefly and clone the inner `Arc`
128/// (refcount bump — no payload copy). Writers go through [`Self::refresh_caches`].
129#[derive(Debug)]
130pub struct IsometryPenalty {
131 pub target: PsiSlice,
132 pub reference: IsometryReference,
133 /// Index of this penalty's strength `log μ_iso` inside the *local* rho
134 /// view this penalty receives. Always `0` for now (single owned axis).
135 pub rho_index: usize,
136 /// Cached Jacobian `J ∈ ℝ^{n_obs × p × d}`, flattened row-major
137 /// `(n_obs, p*d)`. The owning driver refreshes this each IFT outer step
138 /// before invoking `value` / `grad_target`; in operator-only call sites
139 /// (Hessian-vector products) the cache must be live. Access through
140 /// [`Self::jacobian_cache`] / [`Self::set_jacobian_cache`].
141 pub jacobian_cache_slot: RwLock<Option<Arc<Array2<f64>>>>,
142 /// Optional cached per-row Jacobian *second derivative*
143 /// `H_n ∈ ℝ^{p × d × d}`, flattened row-major as `(n_obs, p*d*d)`.
144 /// `H_n[i, a, c] = ∂J_n[i, a] / ∂t_{n, c}`. Either this cache or
145 /// `duchon_radial_source` must be present for exact isometry
146 /// gradient/HVP calls. Access through [`Self::jacobian_second_cache`] /
147 /// [`Self::set_jacobian_second_cache`].
148 pub jacobian_second_cache_slot: RwLock<Option<Arc<Array2<f64>>>>,
149 /// Optional radial-Duchon source used to build `jacobian_second_cache`
150 /// analytically from `φ'(r)` and the public `φ''(r)` jet helper. This is
151 /// the exact chain-rule path for callers that do not pre-cache `∂J/∂t`.
152 pub duchon_radial_source: Option<Arc<IsometryDuchonRadialSource>>,
153 /// Optional cached per-row Jacobian *third derivative*
154 /// `K_n ∈ ℝ^{p × d × d × d}`, stored as an `Array3` with shape
155 /// `(n_obs, p, d * d * d)` where the third axis packs `(a, c, d)` in
156 /// row-major order `((a * d) + c) * d + dd`. `hvp` uses the full
157 /// residual-curvature Hessian (proposal §4(b)):
158 /// B_{ab,cd} = K_{a,cd}^T W J_b + H_{a,c}^T W H_{b,d}
159 /// + H_{a,d}^T W H_{b,c} + J_a^T W K_{b,cd}.
160 /// Either this cache or `duchon_radial_source` must be present for
161 /// analytic `hvp` calls. Interior-mutable (mirrors
162 /// `jacobian_second_cache_slot`) so the SAE outer loop can refresh `K` in
163 /// place each step. Access through [`Self::third_decoder_derivative`] /
164 /// [`Self::set_third_decoder_derivative`].
165 pub third_decoder_derivative_slot: RwLock<Option<Arc<ndarray::Array3<f64>>>>,
166 /// Output dimensionality `p` (column count of each per-row Jacobian).
167 pub p_out: usize,
168 /// Per-row behavioral metric in low-rank factored form. Defaults to
169 /// `Identity` (the unweighted `J^T J` pullback). When `Factored`, all
170 /// `g_n` contractions are done via `M_n = U_n^T J_n` (`r × d`), keeping
171 /// memory and FLOPs scaling at `O(p · r · d)` per row instead of
172 /// `O(p²)` per row.
173 pub weight: WeightField,
174 pub scalar_weight: f64,
175 pub weight_schedule: Option<ScalarWeightSchedule>,
176}
177
178pub(crate) struct IsometryHvpState<'a> {
179 d: usize,
180 n_obs: usize,
181 p: usize,
182 jac2: CowArray<'a, f64, Ix2>,
183 jac3: CowArray<'a, f64, Ix3>,
184 metric: IsometryMetricState,
185 wj_rows: Vec<Array2<f64>>,
186}
187
188#[derive(Debug, Clone)]
189struct IsometryMetricState {
190 g: Array2<f64>,
191 residual: Array2<f64>,
192 metric_grad: Array2<f64>,
193 normalizer: f64,
194 trace_denominator: f64,
195 residual_dot_g: f64,
196}
197
198impl IsometryMetricState {
199 fn residual_direction(&self, delta_g: ArrayView2<'_, f64>, d: usize) -> (Array2<f64>, f64) {
200 let n_obs = self.g.nrows();
201 let dd = d * d;
202 let mut delta_trace_sum = 0.0;
203 for n in 0..n_obs {
204 for a in 0..d {
205 delta_trace_sum += delta_g[[n, a * d + a]];
206 }
207 }
208 let delta_normalizer = delta_trace_sum / self.trace_denominator;
209 let inv_norm = 1.0 / self.normalizer;
210 let inv_norm_sq = inv_norm * inv_norm;
211 let mut delta_residual = Array2::<f64>::zeros((n_obs, dd));
212 for n in 0..n_obs {
213 for k in 0..dd {
214 delta_residual[[n, k]] =
215 delta_g[[n, k]] * inv_norm - self.g[[n, k]] * delta_normalizer * inv_norm_sq;
216 }
217 }
218 (delta_residual, delta_normalizer)
219 }
220
221 fn metric_grad_direction(&self, delta_g: ArrayView2<'_, f64>, d: usize) -> Array2<f64> {
222 let n_obs = self.g.nrows();
223 let dd = d * d;
224 let (delta_residual, delta_normalizer) = self.residual_direction(delta_g, d);
225 let mut delta_residual_dot_g = 0.0;
226 for n in 0..n_obs {
227 for k in 0..dd {
228 delta_residual_dot_g += delta_residual[[n, k]] * self.g[[n, k]];
229 delta_residual_dot_g += self.residual[[n, k]] * delta_g[[n, k]];
230 }
231 }
232 let inv_norm = 1.0 / self.normalizer;
233 let inv_norm_sq = inv_norm * inv_norm;
234 let delta_trace_coeff = delta_residual_dot_g * inv_norm_sq / self.trace_denominator
235 - 2.0 * self.residual_dot_g * delta_normalizer * inv_norm_sq * inv_norm
236 / self.trace_denominator;
237 let mut out = Array2::<f64>::zeros((n_obs, dd));
238 for n in 0..n_obs {
239 for a in 0..d {
240 for b in 0..d {
241 let k = a * d + b;
242 let mut value = delta_residual[[n, k]] * inv_norm
243 - self.residual[[n, k]] * delta_normalizer * inv_norm_sq;
244 if a == b {
245 value -= delta_trace_coeff;
246 }
247 out[[n, k]] = value;
248 }
249 }
250 }
251 out
252 }
253}
254
255/// Average trace per latent dimension `(1 / (N d)) Σ_n tr(m_n)` of a flattened
256/// `(n_obs, d*d)` row-major metric field. Shared by the decoder normalizer
257/// `gbar` and the reference normalizer `gref_bar`, so a decoder metric that is
258/// exactly proportional to a reference of arbitrary scale gives a zero residual.
259fn average_trace_per_dim(m: ArrayView2<'_, f64>, n_obs: usize, d: usize) -> f64 {
260 let denom = (n_obs * d) as f64;
261 let mut trace_sum = 0.0;
262 for n in 0..n_obs {
263 for a in 0..d {
264 trace_sum += m[[n, a * d + a]];
265 }
266 }
267 trace_sum / denom
268}
269
270fn isometry_dg_entry(
271 jac2: ArrayView2<'_, f64>,
272 wj: ArrayView2<'_, f64>,
273 n: usize,
274 d: usize,
275 p: usize,
276 a: usize,
277 b: usize,
278 c: usize,
279) -> f64 {
280 let mut s = 0.0;
281 for i in 0..p {
282 s += jac2[[n, (i * d + a) * d + c]] * wj[[i, b]];
283 s += wj[[i, a]] * jac2[[n, (i * d + b) * d + c]];
284 }
285 s
286}
287
288fn isometry_row_delta_g(
289 jac2: ArrayView2<'_, f64>,
290 wj: ArrayView2<'_, f64>,
291 v: ArrayView1<'_, f64>,
292 n: usize,
293 d: usize,
294 p: usize,
295) -> Array2<f64> {
296 let mut delta_g = Array2::<f64>::zeros((d, d));
297 for a in 0..d {
298 for b in 0..d {
299 let mut s = 0.0;
300 for c in 0..d {
301 s += isometry_dg_entry(jac2, wj, n, d, p, a, b, c) * v[n * d + c];
302 }
303 delta_g[[a, b]] = s;
304 }
305 }
306 delta_g
307}
308
309impl IsometryPenalty {
310 pub const DEFAULT_VALUE_ON_MISSING_CACHE: f64 = 0.0;
311
312 #[must_use]
313 pub fn new_euclidean(target: PsiSlice, p_out: usize) -> Self {
314 Self {
315 target,
316 reference: IsometryReference::Euclidean,
317 rho_index: 0,
318 jacobian_cache_slot: RwLock::new(None),
319 jacobian_second_cache_slot: RwLock::new(None),
320 duchon_radial_source: None,
321 third_decoder_derivative_slot: RwLock::new(None),
322 p_out,
323 weight: WeightField::Identity,
324 scalar_weight: 1.0,
325 weight_schedule: None,
326 }
327 }
328
329 /// Read-side accessor: takes the read lock briefly and clones the inner
330 /// `Arc` (refcount bump only; no payload copy). Returns `None` when the
331 /// cache has not been refreshed yet. Internally panics on poisoned lock
332 /// — the lock only wraps an `Option<Arc<…>>`, so the write side cannot
333 /// leave it in an invariant-violating state.
334 #[must_use]
335 pub fn jacobian_cache(&self) -> Option<Arc<Array2<f64>>> {
336 self.jacobian_cache_slot
337 .read()
338 .expect("IsometryPenalty::jacobian_cache_slot poisoned")
339 .clone()
340 }
341
342 /// Read the Jacobian cache under the *dimensional identity*
343 /// `(latent_dim, p_out)` of the atom currently being evaluated.
344 ///
345 /// The cache is interior-mutable and refreshed **per atom** (see
346 /// [`Self::refresh_caches`] and
347 /// `sae::manifold::refresh_isometry_caches_from_atom`). In a heterogeneous
348 /// SAE whose atoms have *mixed* latent dimensions (e.g. the standard zoo
349 /// `dims = [1,1,2,2,2,2,2,1]`) the slot can transiently hold a Jacobian
350 /// built for a **different** atom's `latent_dim` — its column count is
351 /// `p_out · d'` for that atom's `d'`, not this atom's `d`. Reshaping such a
352 /// cache at the wrong `d` would silently corrupt every downstream `J`
353 /// contraction (`projected_jacobian_row` / `weighted_jacobian_row`) or trip
354 /// the [`Self::pullback_metric`] shape invariant (`assert_eq!(jac.ncols(),
355 /// p·d)`) with a hard panic (issue #2294).
356 ///
357 /// The cache's built-for `latent_dim` is recoverable from its own shape:
358 /// with `p_out` fixed on the penalty, `ncols() == p_out · d_cache`. We
359 /// Per-atom SAE evaluation clones the registry descriptor, retargets it,
360 /// and refreshes that clone before any read, so a mismatched live cache is
361 /// an ownership/refresh invariant violation, not missing optional data.
362 /// Silently converting it to `None` would disable the isometry penalty and
363 /// change the fitted objective. Keep the mismatch hard: every caller sees
364 /// either no cache at all or a cache with the exact requested identity.
365 fn dimensioned_jacobian_cache(
366 &self,
367 method: &str,
368 latent_dim: usize,
369 ) -> Option<Arc<Array2<f64>>> {
370 let Some(jac) = self.jacobian_cache() else {
371 self.missing_cache_default(method, "jacobian_cache is None");
372 return None;
373 };
374 let expected = self
375 .p_out
376 .checked_mul(latent_dim)
377 .expect("IsometryPenalty Jacobian dimensional identity overflow");
378 assert_eq!(
379 jac.ncols(),
380 expected,
381 "IsometryPenalty::{method} stale cross-atom Jacobian cache: cache has {} columns, \
382 but this per-atom evaluation requires p_out {} × latent_dim {} = {}; the owner \
383 must clone, retarget, and refresh the penalty before evaluation",
384 jac.ncols(),
385 self.p_out,
386 latent_dim,
387 expected,
388 );
389 Some(jac)
390 }
391
392 /// Read-side accessor for the per-row Jacobian second derivative.
393 /// Mirrors [`Self::jacobian_cache`].
394 #[must_use]
395 pub fn jacobian_second_cache(&self) -> Option<Arc<Array2<f64>>> {
396 self.jacobian_second_cache_slot
397 .read()
398 .expect("IsometryPenalty::jacobian_second_cache_slot poisoned")
399 .clone()
400 }
401
402 /// Per-step refresh entry point. Takes `&self` (no `&mut`) so the SAE
403 /// outer loop can install fresh caches on an `Arc<IsometryPenalty>` held
404 /// in the analytic-penalty registry without disturbing the surrounding
405 /// dispatcher. Pass `None` for either argument to clear that cache (the
406 /// dispatcher will then either fall back to the Duchon radial source if
407 /// available, or return the zero safe default).
408 pub fn refresh_caches(&self, jac: Option<Arc<Array2<f64>>>, jac2: Option<Arc<Array2<f64>>>) {
409 *self
410 .jacobian_cache_slot
411 .write()
412 .expect("IsometryPenalty::jacobian_cache_slot poisoned") = jac;
413 *self
414 .jacobian_second_cache_slot
415 .write()
416 .expect("IsometryPenalty::jacobian_second_cache_slot poisoned") = jac2;
417 }
418
419 /// In-place writer for just the Jacobian cache (used by callers that
420 /// already own the radial Duchon source and only want to refresh `J`).
421 pub fn set_jacobian_cache(&self, jac: Option<Arc<Array2<f64>>>) {
422 *self
423 .jacobian_cache_slot
424 .write()
425 .expect("IsometryPenalty::jacobian_cache_slot poisoned") = jac;
426 }
427
428 /// In-place writer for just the Jacobian second-derivative cache.
429 pub fn set_jacobian_second_cache(&self, jac2: Option<Arc<Array2<f64>>>) {
430 *self
431 .jacobian_second_cache_slot
432 .write()
433 .expect("IsometryPenalty::jacobian_second_cache_slot poisoned") = jac2;
434 }
435
436 /// Read-side accessor for the per-row Jacobian third derivative `K`.
437 /// Mirrors [`Self::jacobian_second_cache`].
438 #[must_use]
439 pub fn third_decoder_derivative(&self) -> Option<Arc<ndarray::Array3<f64>>> {
440 self.third_decoder_derivative_slot
441 .read()
442 .expect("IsometryPenalty::third_decoder_derivative_slot poisoned")
443 .clone()
444 }
445
446 /// In-place writer for just the Jacobian third-derivative cache `K`.
447 pub fn set_third_decoder_derivative(&self, jac3: Option<Arc<ndarray::Array3<f64>>>) {
448 *self
449 .third_decoder_derivative_slot
450 .write()
451 .expect("IsometryPenalty::third_decoder_derivative_slot poisoned") = jac3;
452 }
453}
454
455impl Clone for IsometryPenalty {
456 fn clone(&self) -> Self {
457 Self {
458 target: self.target.clone(),
459 reference: self.reference.clone(),
460 rho_index: self.rho_index,
461 jacobian_cache_slot: RwLock::new(self.jacobian_cache()),
462 jacobian_second_cache_slot: RwLock::new(self.jacobian_second_cache()),
463 duchon_radial_source: self.duchon_radial_source.clone(),
464 third_decoder_derivative_slot: RwLock::new(self.third_decoder_derivative()),
465 p_out: self.p_out,
466 weight: self.weight.clone(),
467 scalar_weight: self.scalar_weight,
468 weight_schedule: self.weight_schedule.clone(),
469 }
470 }
471}
472
473impl IsometryPenalty {
474 /// Attach a cached third decoder derivative
475 /// `K_n[i, a, c, d] = ∂²J_n[i, a] / ∂t_{n, c} ∂t_{n, d}`, flattened
476 /// row-major as `(n_obs, p * d * d * d)`. The Hessian-vector product
477 /// uses the full residual-curvature term in addition to the metric
478 /// Gauss-Newton piece.
479 #[must_use]
480 pub fn with_third_decoder_derivative(self, k: Arc<ndarray::Array3<f64>>) -> Self {
481 self.set_third_decoder_derivative(Some(k));
482 self
483 }
484
485 #[must_use]
486 pub fn with_reference(mut self, reference: IsometryReference) -> Self {
487 self.reference = reference;
488 self
489 }
490
491 #[must_use]
492 pub fn with_jacobian_cache(self, j: Arc<Array2<f64>>) -> Self {
493 self.set_jacobian_cache(Some(j));
494 self
495 }
496
497 #[must_use]
498 pub fn with_jacobian_second_cache(self, h: Arc<Array2<f64>>) -> Self {
499 self.set_jacobian_second_cache(Some(h));
500 self
501 }
502
503 /// Attach radial Duchon decoder metadata so the exact `∂J/∂t` tensor can
504 /// be rebuilt from the current target coordinates. A doc-test oracle for
505 /// this path is: build `J(t)` from `duchon_radial_first_derivative_nd`,
506 /// evaluate `grad_target(t)`, then central-difference `value(t ± h e_j)`;
507 /// the analytic component should agree to finite-difference tolerance as
508 /// `h` is refined before cancellation dominates.
509 #[must_use]
510 pub fn with_duchon_radial_source(mut self, source: Arc<IsometryDuchonRadialSource>) -> Self {
511 self.duchon_radial_source = Some(source);
512 self
513 }
514
515 /// Attach the gauge metric **from the single
516 /// [`RowMetric`](gam_problem::RowMetric)** that also drives
517 /// the reconstruction likelihood. This is the only way an `IsometryPenalty`
518 /// acquires a non-identity behavioral metric: the independent
519 /// `WeightField` setter has been removed so a gauge-metric ≠
520 /// likelihood-metric state is structurally unrepresentable. The
521 /// contraction-order invariant (`M_n = U_n^T J_n`, never materializing the
522 /// `p × p` `W_n`) is preserved by the [`WeightField::Factored`] layout the
523 /// metric emits.
524 ///
525 /// `p_out` is taken from the metric so the gauge's output dimension is
526 /// pinned to the metric's.
527 #[must_use]
528 pub fn with_row_metric(mut self, metric: &gam_problem::RowMetric) -> Self {
529 // Only a metric that drives the gauge installs a non-identity pullback
530 // weight. A Euclidean metric reduces the gauge pullback to the bare
531 // `J_nᵀ J_n`, so its `to_weight_field()` is `Identity` and the existing
532 // (default-Identity) weight is left exactly as is — bit-for-bit the
533 // pre-metric isotropic gauge. The output dimension is pinned to the
534 // metric's regardless, so the gauge and likelihood agree on `p_out`.
535 if metric.drives_gauge() {
536 self.weight = metric.to_weight_field();
537 }
538 self.p_out = metric.p_out();
539 self
540 }
541
542 impl_with_weight_schedule!(scalar_weight);
543
544 fn missing_cache_default(&self, method: &str, detail: &str) {
545 log::warn!(
546 "IsometryPenalty::{method} missing required derivative state: {detail}; \
547 returning the zero safe default"
548 );
549 }
550
551 fn has_jacobian_cache(&self, method: &str) -> bool {
552 if self.jacobian_cache().is_some() {
553 true
554 } else {
555 self.missing_cache_default(method, "jacobian_cache is None");
556 false
557 }
558 }
559
560 fn has_jacobian_second_source(&self, method: &str) -> bool {
561 if self.jacobian_second_cache().is_some() || self.duchon_radial_source.is_some() {
562 true
563 } else {
564 self.missing_cache_default(
565 method,
566 "both jacobian_second_cache and duchon_radial_source are None",
567 );
568 false
569 }
570 }
571
572 fn has_jacobian_third_source(&self, method: &str) -> bool {
573 if self.third_decoder_derivative().is_some() || self.duchon_radial_source.is_some() {
574 true
575 } else {
576 self.missing_cache_default(
577 method,
578 "both third_decoder_derivative cache and duchon_radial_source are None",
579 );
580 false
581 }
582 }
583
584 /// Build `M_n = U_n^T J_n ∈ ℝ^{r_n × d}` for row `n`. For
585 /// `WeightField::Identity`, `r_n = p` and `M_n = J_n`.
586 ///
587 /// This is the single contraction site where `W_n` (or its `U_n` factor)
588 /// is consumed. Every value/grad/hvp path funnels through here, so the
589 /// `(J^T U)(U^T J)` ordering invariant cannot be violated by accident.
590 fn projected_jacobian_row(&self, n: usize, d: usize) -> Option<Array2<f64>> {
591 let jac = self.dimensioned_jacobian_cache("projected_jacobian_row", d)?;
592 let jac_row = jac.row(n);
593 let jac_slice = jac_row
594 .as_slice()
595 .expect("jacobian cache must be in standard row-major layout");
596 match &self.weight {
597 WeightField::Identity => {
598 let p = self.p_out;
599 let mut m = Array2::<f64>::zeros((p, d));
600 for i in 0..p {
601 for a in 0..d {
602 m[[i, a]] = jac_slice[i * d + a];
603 }
604 }
605 Some(m)
606 }
607 WeightField::Factored { u, rank, p_out } => {
608 let u_row = u.row(n);
609 let u_slice = u_row
610 .as_slice()
611 .expect("weight factor U must be in standard row-major layout");
612 Some(WeightField::project_jac_row_with_u(
613 u_slice, jac_slice, *p_out, *rank, d,
614 ))
615 }
616 }
617 }
618
619 /// Form `W_n J_n` without materializing `W_n`.
620 fn weighted_jacobian_row(&self, n: usize, d: usize) -> Option<Array2<f64>> {
621 let jac = self.dimensioned_jacobian_cache("weighted_jacobian_row", d)?;
622 let p = self.p_out;
623 match &self.weight {
624 WeightField::Identity => {
625 let mut out = Array2::<f64>::zeros((p, d));
626 for i in 0..p {
627 for a in 0..d {
628 out[[i, a]] = jac[[n, i * d + a]];
629 }
630 }
631 Some(out)
632 }
633 WeightField::Factored { u, rank, p_out } => {
634 assert_eq!(p, *p_out);
635 let r = *rank;
636 let m_n = self.projected_jacobian_row(n, d)?;
637 let mut out = Array2::<f64>::zeros((p, d));
638 for i in 0..p {
639 for a in 0..d {
640 let mut s = 0.0;
641 for k in 0..r {
642 s += u[[n, i * r + k]] * m_n[[k, a]];
643 }
644 out[[i, a]] = s;
645 }
646 }
647 Some(out)
648 }
649 }
650 }
651
652 fn weighted_dot_decoder_vectors<F, G>(&self, n: usize, p: usize, x: F, y: G) -> f64
653 where
654 F: Fn(usize) -> f64,
655 G: Fn(usize) -> f64,
656 {
657 match &self.weight {
658 WeightField::Identity => {
659 let mut s = 0.0;
660 for i in 0..p {
661 s += x(i) * y(i);
662 }
663 s
664 }
665 WeightField::Factored { u, rank, p_out } => {
666 assert_eq!(p, *p_out);
667 let r = *rank;
668 let mut s = 0.0;
669 for k in 0..r {
670 let mut ux = 0.0;
671 let mut uy = 0.0;
672 for i in 0..p {
673 let uik = u[[n, i * r + k]];
674 ux += uik * x(i);
675 uy += uik * y(i);
676 }
677 s += ux * uy;
678 }
679 s
680 }
681 }
682 }
683
684 fn target_matrix(target: ArrayView1<'_, f64>, n_obs: usize, d: usize) -> Array2<f64> {
685 let mut out = Array2::<f64>::zeros((n_obs, d));
686 for n in 0..n_obs {
687 for a in 0..d {
688 out[[n, a]] = target[n * d + a];
689 }
690 }
691 out
692 }
693
694 /// Second-order input-location derivative tensor of the Duchon decoder,
695 /// flattened to `(n_obs, p_out · d²)` with column layout
696 /// `i·d² + (a·d + c)`.
697 ///
698 /// Thin adapter over the shared [`radial_basis_cartesian_derivative`]
699 /// engine: it owns the radial-jet evaluation and the radial→Cartesian map;
700 /// here we only forward the source geometry.
701 fn duchon_radial_jacobian_second(
702 &self,
703 target: ArrayView1<'_, f64>,
704 n_obs: usize,
705 d: usize,
706 source: &IsometryDuchonRadialSource,
707 ) -> Result<Array2<f64>, BasisError> {
708 assert_eq!(source.centers.ncols(), d);
709 assert_eq!(source.radial_coefficients.nrows(), source.centers.nrows());
710 assert_eq!(source.radial_coefficients.ncols(), self.p_out);
711 let t = Self::target_matrix(target, n_obs, d);
712 radial_basis_cartesian_derivative(
713 2,
714 t.view(),
715 source.centers.view(),
716 source.radial_coefficients.view(),
717 source.length_scale,
718 source.nullspace_order,
719 source.power,
720 )
721 }
722
723 /// Third-order input-location derivative tensor of the Duchon decoder,
724 /// shaped `(n_obs, p_out, d³)` with last-axis layout `(a·d + c)·d + e`.
725 ///
726 /// Thin adapter over the shared [`radial_basis_cartesian_derivative`]
727 /// engine; the flat `(n_obs, p_out · d³)` result is reshaped to the
728 /// `Array3` consumed by the HVP path (row-major flatten of `(p_out, d³)`
729 /// is exactly `i·d³ + idx`).
730 fn duchon_radial_jacobian_third(
731 &self,
732 target: ArrayView1<'_, f64>,
733 n_obs: usize,
734 d: usize,
735 source: &IsometryDuchonRadialSource,
736 ) -> Result<ndarray::Array3<f64>, BasisError> {
737 assert_eq!(source.centers.ncols(), d);
738 assert_eq!(source.radial_coefficients.nrows(), source.centers.nrows());
739 assert_eq!(source.radial_coefficients.ncols(), self.p_out);
740 let t = Self::target_matrix(target, n_obs, d);
741 let flat = radial_basis_cartesian_derivative(
742 3,
743 t.view(),
744 source.centers.view(),
745 source.radial_coefficients.view(),
746 source.length_scale,
747 source.nullspace_order,
748 source.power,
749 )?;
750 Ok(flat
751 .into_shape_with_order((n_obs, self.p_out, d * d * d))
752 .expect("radial_basis_cartesian_derivative order-3 output reshapes to (n_obs, p, d³)"))
753 }
754
755 fn jacobian_second<'a>(
756 &'a self,
757 target: ArrayView1<'_, f64>,
758 n_obs: usize,
759 d: usize,
760 ) -> Option<CowArray<'a, f64, Ix2>> {
761 if let Some(jac2) = self.jacobian_second_cache() {
762 // Clone the underlying Array2 to detach from the Arc — the
763 // CowArray needs to outlive the temporary Arc returned by the
764 // accessor. The clone is `n_obs × p·d²` floats, paid once per
765 // grad_target / hvp_state invocation; same per-step cost as the
766 // pre-refactor code path which also took ownership via
767 // `jac2.view().to_owned()` semantics implicitly.
768 return Some(CowArray::from((*jac2).clone()));
769 }
770 let source = self.duchon_radial_source.as_ref()?;
771 match self.duchon_radial_jacobian_second(target, n_obs, d, source) {
772 Ok(jac2) => Some(CowArray::from(jac2)),
773 Err(err) => {
774 self.missing_cache_default(
775 "jacobian_second",
776 &format!("failed to materialize Duchon radial second derivative: {err}"),
777 );
778 None
779 }
780 }
781 }
782
783 fn jacobian_third<'a>(
784 &'a self,
785 target: ArrayView1<'_, f64>,
786 n_obs: usize,
787 d: usize,
788 ) -> Option<CowArray<'a, f64, Ix3>> {
789 if let Some(jac3) = self.third_decoder_derivative() {
790 return Some(CowArray::from(jac3.as_ref().clone()));
791 }
792 let source = self.duchon_radial_source.as_ref()?;
793 match self.duchon_radial_jacobian_third(target, n_obs, d, source) {
794 Ok(jac3) => Some(CowArray::from(jac3)),
795 Err(err) => {
796 self.missing_cache_default(
797 "jacobian_third",
798 &format!("failed to materialize Duchon radial third derivative: {err}"),
799 );
800 None
801 }
802 }
803 }
804
805 pub(crate) fn hvp_state<'a>(
806 &'a self,
807 target: ArrayView1<'_, f64>,
808 ) -> Option<IsometryHvpState<'a>> {
809 let d = self
810 .target
811 .latent_dim
812 .expect("IsometryPenalty requires latent_dim on its PsiSlice");
813 let n_obs = target.len() / d;
814 if !self.has_jacobian_cache("hvp")
815 || !self.has_jacobian_second_source("hvp")
816 || !self.has_jacobian_third_source("hvp")
817 {
818 return None;
819 }
820 let p = self.p_out;
821 let jac2 = self.jacobian_second(target.view(), n_obs, d)?;
822 let jac3 = self.jacobian_third(target.view(), n_obs, d)?;
823 let g = self.pullback_metric(d)?;
824 let metric = self.normalized_metric_state(g, n_obs, d)?;
825 let mut wj_rows = Vec::with_capacity(n_obs);
826 for n in 0..n_obs {
827 wj_rows.push(self.weighted_jacobian_row(n, d)?);
828 }
829 Some(IsometryHvpState {
830 d,
831 n_obs,
832 p,
833 jac2,
834 jac3,
835 metric,
836 wj_rows,
837 })
838 }
839
840 pub(crate) fn hvp_with_precomputed_state(
841 &self,
842 state: &IsometryHvpState<'_>,
843 rho: ArrayView1<'_, f64>,
844 v: ArrayView1<'_, f64>,
845 ) -> Array1<f64> {
846 let mu = validated_learnable_weight(self.scalar_weight, rho[self.rho_index]);
847 let d = state.d;
848 let n_obs = state.n_obs;
849 let p = state.p;
850 let jac2 = &state.jac2;
851 let jac3 = &state.jac3;
852 let metric = &state.metric;
853 let mut out = Array1::<f64>::zeros(v.len());
854 let mut delta_g = Array2::<f64>::zeros((n_obs, d * d));
855 for n in 0..n_obs {
856 let wj = &state.wj_rows[n];
857 let row_delta = isometry_row_delta_g(jac2.view(), wj.view(), v, n, d, p);
858 for a in 0..d {
859 for b in 0..d {
860 delta_g[[n, a * d + b]] = row_delta[[a, b]];
861 }
862 }
863 }
864 let delta_metric_grad = metric.metric_grad_direction(delta_g.view(), d);
865
866 for n in 0..n_obs {
867 let wj = &state.wj_rows[n];
868 for c in 0..d {
869 let mut acc = 0.0;
870 for a in 0..d {
871 for b in 0..d {
872 let dg = isometry_dg_entry(jac2.view(), wj.view(), n, d, p, a, b, c);
873 acc += dg * delta_metric_grad[[n, a * d + b]];
874 }
875 }
876 out[n * d + c] = mu * acc;
877 }
878
879 for c in 0..d {
880 let mut acc_res = 0.0;
881 for a in 0..d {
882 for b in 0..d {
883 let metric_grad = metric.metric_grad[[n, a * d + b]];
884 if metric_grad == 0.0 {
885 continue;
886 }
887 let mut bv = 0.0;
888 for dd in 0..d {
889 let vd = v[n * d + dd];
890 if vd == 0.0 {
891 continue;
892 }
893 let mut k_a_cd_w_j_b = 0.0;
894 for i in 0..p {
895 k_a_cd_w_j_b += jac3[[n, i, ((a * d) + c) * d + dd]] * wj[[i, b]];
896 }
897 let h_a_c_w_h_b_d = self.weighted_dot_decoder_vectors(
898 n,
899 p,
900 |i| jac2[[n, (i * d + a) * d + c]],
901 |i| jac2[[n, (i * d + b) * d + dd]],
902 );
903 let h_a_d_w_h_b_c = self.weighted_dot_decoder_vectors(
904 n,
905 p,
906 |i| jac2[[n, (i * d + a) * d + dd]],
907 |i| jac2[[n, (i * d + b) * d + c]],
908 );
909 let mut j_a_w_k_b_cd = 0.0;
910 for i in 0..p {
911 j_a_w_k_b_cd += wj[[i, a]] * jac3[[n, i, ((b * d) + c) * d + dd]];
912 }
913 bv +=
914 (k_a_cd_w_j_b + h_a_c_w_h_b_d + h_a_d_w_h_b_c + j_a_w_k_b_cd) * vd;
915 }
916 acc_res += metric_grad * bv;
917 }
918 }
919 out[n * d + c] += mu * acc_res;
920 }
921 }
922 out
923 }
924
925 /// Per-row pullback metric `g_n = J_n^T W_n J_n = M_n^T M_n` with
926 /// `M_n = U_n^T J_n ∈ ℝ^{r_n × d}`. Returns `(n_obs, d, d)` flattened
927 /// row-major as `(n_obs, d*d)`.
928 ///
929 /// Cost per row: `O(p · r · d)` for the `M_n` build (single pass over
930 /// `U_n` and `J_n`) plus `O(r · d²)` for `M_n^T M_n`. The `p × p` weight
931 /// `W_n` is never materialized.
932 pub fn pullback_metric(&self, latent_dim: usize) -> Option<Array2<f64>> {
933 let jac = self.dimensioned_jacobian_cache("pullback_metric", latent_dim)?;
934 let n_obs = jac.nrows();
935 // `dimensioned_jacobian_cache` enforces the load-bearing `(n, p·d)`
936 // shape contract before the reshape loop below. A stale cross-atom
937 // cache is a hard owner/refresh invariant failure; it is never converted
938 // into a zero isometry contribution (#2294).
939 let mut g_all = Array2::<f64>::zeros((n_obs, latent_dim * latent_dim));
940 for n in 0..n_obs {
941 // M_n = U_n^T J_n (or J_n itself when W = I).
942 let m = self.projected_jacobian_row(n, latent_dim)?;
943 let r = m.nrows();
944 // g_n = M_n^T M_n: (d × d) result, contracting r.
945 for a in 0..latent_dim {
946 for b in 0..latent_dim {
947 let mut s = 0.0;
948 for k in 0..r {
949 s += m[[k, a]] * m[[k, b]];
950 }
951 g_all[[n, a * latent_dim + b]] = s;
952 }
953 }
954 }
955 Some(g_all)
956 }
957
958 /// The scale normalizer `gbar = (1 / (N d)) Σ_n tr(g_n)` of the cached
959 /// pullback metric — the single shared denominator the scale-invariant
960 /// gauge divides every per-row metric by.
961 ///
962 /// `value` / `grad_*` / `hvp` consume this implicitly through
963 /// [`Self::normalized_metric_state`]; the SAE arrow-Schur assembly cannot
964 /// (it builds explicit per-row `htt` / `htbeta` / `hbb` curvature blocks
965 /// from the raw pullback `g_n`, not through the trait operators), so it
966 /// reads `gbar` here and folds `1/gbar²` into its Gauss-Newton curvature.
967 /// That `1/gbar²` factor is exactly the frozen-normalizer Gauss-Newton
968 /// block of the normalized residual `R_n = g_n/gbar − g^ref_n`: the raw
969 /// block (the GN block of the *un-normalized* `½μ‖g_n − g^ref‖²`) scales
970 /// ∝‖B‖⁴ in the decoder magnitude while the normalized gradient is
971 /// scale-free, so without the factor the joint Newton step collapses and
972 /// the proximal ridge saturates at 1e15 (#795). It stays PSD (a positive
973 /// scalar on an already-PSD Gram block), so the Schur complement is
974 /// unaffected. `None` when the metric is unavailable or degenerate, mirror-
975 /// ing `normalized_metric_state`'s non-positive-normalizer guard.
976 pub fn metric_normalizer(&self, latent_dim: usize) -> Option<f64> {
977 let g = self.pullback_metric(latent_dim)?;
978 let n_obs = g.nrows();
979 let normalizer = average_trace_per_dim(g.view(), n_obs, latent_dim);
980 (normalizer.is_finite() && normalizer > f64::MIN_POSITIVE).then_some(normalizer)
981 }
982
983 /// Reference metric per row for the normalized pullback metric, `(n_obs, d*d)`.
984 fn reference_metric(&self, n_obs: usize, d: usize) -> CowArray<'_, f64, Ix2> {
985 match &self.reference {
986 IsometryReference::Euclidean => {
987 let mut out = Array2::<f64>::zeros((n_obs, d * d));
988 for n in 0..n_obs {
989 for a in 0..d {
990 out[[n, a * d + a]] = 1.0;
991 }
992 }
993 CowArray::from(out)
994 }
995 IsometryReference::UserSupplied(a) => {
996 assert_eq!(a.nrows(), n_obs);
997 assert_eq!(a.ncols(), d * d);
998 CowArray::from(a.view())
999 }
1000 }
1001 }
1002
1003 /// Shared normalized metric state for the scale-invariant isometry gauge.
1004 ///
1005 /// The residual is `R_n = g_n / gbar - g_ref,n / gref_bar`, with
1006 /// `gbar = (1 / (N d)) Σ_n tr(g_n)` and `gref_bar = (1 / (N d)) Σ_n tr(g_ref,n)`
1007 /// (`gref_bar == 1` for the `Euclidean` reference). `g_ref / gref_bar` is
1008 /// constant w.r.t. the decoder coordinates, so the metric gradient/Hessian
1009 /// form below is unchanged. The metric-gradient is the exact
1010 /// derivative of `0.5 Σ ||R_n||²` with respect to the raw pullback metrics:
1011 ///
1012 /// `A_n = R_n / gbar - (Σ_l R_l:g_l) I / (gbar² N d)`.
1013 ///
1014 /// All value, gradient, and HVP paths consume this state so the global
1015 /// normalizer's derivative is never detached.
1016 fn normalized_metric_state(
1017 &self,
1018 g: Array2<f64>,
1019 n_obs: usize,
1020 d: usize,
1021 ) -> Option<IsometryMetricState> {
1022 let dd = d * d;
1023 let trace_denominator = (n_obs * d) as f64;
1024 let normalizer = average_trace_per_dim(g.view(), n_obs, d);
1025 if !(normalizer.is_finite() && normalizer > f64::MIN_POSITIVE) {
1026 self.missing_cache_default(
1027 "normalized_metric_state",
1028 &format!(
1029 "unit-average-speed normalizer is non-positive or non-finite: {normalizer}"
1030 ),
1031 );
1032 return None;
1033 }
1034 let g_ref = self.reference_metric(n_obs, d);
1035 // Normalize the reference by its own average trace per dim so the gauge
1036 // is scale-invariant on both sides: a decoder metric proportional to the
1037 // reference (up to an arbitrary global scale, common for external chart
1038 // metrics / GP-LVM warm starts) gives a zero residual. For `Euclidean`,
1039 // `ref_normalizer == 1.0` exactly, preserving the prior behavior bit-for-bit.
1040 let ref_normalizer = average_trace_per_dim(g_ref.view(), n_obs, d);
1041 if !(ref_normalizer.is_finite() && ref_normalizer > f64::MIN_POSITIVE) {
1042 self.missing_cache_default(
1043 "normalized_metric_state",
1044 &format!(
1045 "reference-metric normalizer is non-positive or non-finite: {ref_normalizer}"
1046 ),
1047 );
1048 return None;
1049 }
1050 let mut residual = Array2::<f64>::zeros((n_obs, dd));
1051 let inv_norm = 1.0 / normalizer;
1052 let inv_ref_norm = 1.0 / ref_normalizer;
1053 for n in 0..n_obs {
1054 for k in 0..dd {
1055 residual[[n, k]] = g[[n, k]] * inv_norm - g_ref[[n, k]] * inv_ref_norm;
1056 }
1057 }
1058 let mut residual_dot_g = 0.0;
1059 for n in 0..n_obs {
1060 for k in 0..dd {
1061 residual_dot_g += residual[[n, k]] * g[[n, k]];
1062 }
1063 }
1064 let trace_coeff = residual_dot_g / (normalizer * normalizer * trace_denominator);
1065 let mut metric_grad = Array2::<f64>::zeros((n_obs, dd));
1066 for n in 0..n_obs {
1067 for a in 0..d {
1068 for b in 0..d {
1069 let k = a * d + b;
1070 let mut value = residual[[n, k]] * inv_norm;
1071 if a == b {
1072 value -= trace_coeff;
1073 }
1074 metric_grad[[n, k]] = value;
1075 }
1076 }
1077 }
1078 Some(IsometryMetricState {
1079 g,
1080 residual,
1081 metric_grad,
1082 normalizer,
1083 trace_denominator,
1084 residual_dot_g,
1085 })
1086 }
1087
1088 /// Exact closed-form gradient of the isometry penalty with respect to the
1089 /// cached decoder Jacobian `J ∈ ℝ^{n_obs × p × d}` (the autograd input that
1090 /// torch's `_IsometryPenaltyFn` differentiates). Returns the flattened
1091 /// `(n_obs, p*d)` layout that matches the Jacobian cache.
1092 ///
1093 /// Derivation (W-aware, reference-aware, weight-aware):
1094 ///
1095 /// P = ½ μ Σ_n ‖R_n‖²_F,
1096 /// R_n = g_n / gbar − g^ref_n,
1097 /// gbar = (1 / (N d)) Σ_n tr(g_n)
1098 /// A_n = ∂(P/μ)/∂g_n
1099 /// ∂g_{ab}/∂J_{i,c}
1100 /// = δ_{ca}(W J)_{i,b} + δ_{cb}(W J)_{i,a} (W symmetric)
1101 /// ∂P/∂J_{i,c}
1102 /// = μ Σ_{a,b} A_{ab} ∂g_{ab}/∂J_{i,c}
1103 /// = 2 μ Σ_b A_{cb} (W J)_{i,b}
1104 /// = 2 μ ((W J) A)_{i,c}
1105 ///
1106 /// where `A` includes the exact derivative of the shared `gbar` normalizer.
1107 pub fn grad_jacobian(
1108 &self,
1109 target: ArrayView1<'_, f64>,
1110 rho: ArrayView1<'_, f64>,
1111 ) -> Array2<f64> {
1112 let d = self
1113 .target
1114 .latent_dim
1115 .expect("IsometryPenalty requires latent_dim on its PsiSlice");
1116 let n_obs = target.len() / d;
1117 let p = self.p_out;
1118 let mut grad = Array2::<f64>::zeros((n_obs, p * d));
1119 if !self.has_jacobian_cache("grad_jacobian") {
1120 return grad;
1121 }
1122 let Some(g) = self.pullback_metric(d) else {
1123 return grad;
1124 };
1125 let Some(metric) = self.normalized_metric_state(g, n_obs, d) else {
1126 return grad;
1127 };
1128 let mu = validated_learnable_weight(self.scalar_weight, rho[self.rho_index]);
1129 for n in 0..n_obs {
1130 let Some(wj) = self.weighted_jacobian_row(n, d) else {
1131 return Array2::<f64>::zeros((n_obs, p * d));
1132 };
1133 for i in 0..p {
1134 for c in 0..d {
1135 let mut acc = 0.0;
1136 for b in 0..d {
1137 acc += metric.metric_grad[[n, c * d + b]] * wj[[i, b]];
1138 }
1139 grad[[n, i * d + c]] = 2.0 * mu * acc;
1140 }
1141 }
1142 }
1143 grad
1144 }
1145}
1146
1147impl AnalyticPenalty for IsometryPenalty {
1148 fn tier(&self) -> PenaltyTier {
1149 PenaltyTier::Psi
1150 }
1151
1152 fn validate_rho(&self, rho: ArrayView1<'_, f64>) -> Result<(), String> {
1153 if rho.len() != 1 {
1154 return Err(format!("isometry rho length {} != 1", rho.len()));
1155 }
1156 resolve_learnable_weight(self.scalar_weight, rho[self.rho_index]).map(|_| ())
1157 }
1158
1159 fn rho_coordinate_domains(&self) -> Result<Vec<(f64, f64)>, String> {
1160 Ok(vec![
1161 learnable_weight_coordinate_domain(self.scalar_weight)?
1162 .ok_or_else(|| "isometry scalar weight must be positive".to_string())?,
1163 ])
1164 }
1165
1166 fn value(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> f64 {
1167 let d = self
1168 .target
1169 .latent_dim
1170 .expect("IsometryPenalty requires latent_dim on its PsiSlice");
1171 let n_obs = target.len() / d;
1172 if !self.has_jacobian_cache("value") {
1173 return Self::DEFAULT_VALUE_ON_MISSING_CACHE;
1174 }
1175 let Some(g) = self.pullback_metric(d) else {
1176 return Self::DEFAULT_VALUE_ON_MISSING_CACHE;
1177 };
1178 let Some(metric) = self.normalized_metric_state(g, n_obs, d) else {
1179 return Self::DEFAULT_VALUE_ON_MISSING_CACHE;
1180 };
1181 let mu = validated_learnable_weight(self.scalar_weight, rho[self.rho_index]);
1182 let mut acc = 0.0;
1183 for n in 0..n_obs {
1184 for k in 0..(d * d) {
1185 let diff = metric.residual[[n, k]];
1186 acc += diff * diff;
1187 }
1188 }
1189 0.5 * mu * acc
1190 }
1191
1192 fn grad_target(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1193 // Exact closed-form gradient, W-aware:
1194 //
1195 // P = ½ μ Σ_n ‖R_n‖²_F, R_n = g_n / gbar − g^ref_n
1196 // g_n = J_n^T W_n J_n, W_n = U_n U_n^T
1197 // A_n = ∂(P/μ)/∂g_n, including the exact derivative of
1198 // gbar = (1 / (N d)) Σ_n tr(g_n)
1199 // ∂g_{ab}/∂t_c
1200 // = (H_{:,a,c})^T (W J)_{:,b} + (J_{:,a})^T W H_{:,b,c}
1201 // ∂P/∂t_c
1202 // = μ Σ_{a,b} A_{a,b} · ∂g_{ab}/∂t_c
1203 //
1204 // `H = ∂J/∂t` comes either from the live cache or from the radial
1205 // Duchon `φ''(r)` helper. The sign is positive: differentiating
1206 // `t - c` with respect to `t` contributes `+I`.
1207 let d = self
1208 .target
1209 .latent_dim
1210 .expect("IsometryPenalty requires latent_dim on its PsiSlice");
1211 let n_obs = target.len() / d;
1212 if !self.has_jacobian_cache("grad_target")
1213 || !self.has_jacobian_second_source("grad_target")
1214 {
1215 return Array1::<f64>::zeros(target.len());
1216 }
1217 let Some(g) = self.pullback_metric(d) else {
1218 return Array1::<f64>::zeros(target.len());
1219 };
1220 let Some(metric) = self.normalized_metric_state(g, n_obs, d) else {
1221 return Array1::<f64>::zeros(target.len());
1222 };
1223 let p = self.p_out;
1224 let mu = validated_learnable_weight(self.scalar_weight, rho[self.rho_index]);
1225 let mut grad = Array1::<f64>::zeros(target.len());
1226 let Some(jac2) = self.jacobian_second(target, n_obs, d) else {
1227 return grad;
1228 };
1229 assert_eq!(jac2.ncols(), p * d * d);
1230
1231 for n in 0..n_obs {
1232 let Some(wj) = self.weighted_jacobian_row(n, d) else {
1233 return grad;
1234 };
1235 for c in 0..d {
1236 let mut acc = 0.0;
1237 for a in 0..d {
1238 for b in 0..d {
1239 let mut dg = 0.0;
1240 for i in 0..p {
1241 dg += jac2[[n, (i * d + a) * d + c]] * wj[[i, b]];
1242 dg += wj[[i, a]] * jac2[[n, (i * d + b) * d + c]];
1243 }
1244 acc += metric.metric_grad[[n, a * d + b]] * dg;
1245 }
1246 }
1247 grad[n * d + c] = mu * acc;
1248 }
1249 }
1250 grad
1251 }
1252
1253 /// Fully analytic - wired through `radial_basis_cartesian_derivative`.
1254 fn hvp(
1255 &self,
1256 target: ArrayView1<'_, f64>,
1257 rho: ArrayView1<'_, f64>,
1258 v: ArrayView1<'_, f64>,
1259 ) -> Array1<f64> {
1260 // Fully analytic isometry Hessian-vector product wired through the
1261 // shared `radial_basis_cartesian_derivative` engine when no
1262 // third-derivative cache is supplied.
1263 //
1264 // The full Hessian of P_iso = (μ/2) Σ_n ||J^T W J / gbar - G_ref||²_F
1265 // (per proposal §4(b)) is
1266 // μ [Dgᵀ · ∂²(0.5||R||²)/∂g² · Dg + A · ∂²g],
1267 // where R = g/gbar - G_ref and A = ∂(0.5||R||²)/∂g includes the global
1268 // gbar derivative.
1269 // B_{ab,cd} = K_{a,cd}^T W J_b + H_{a,c}^T W H_{b,d}
1270 // + H_{a,d}^T W H_{b,c} + J_a^T W K_{b,cd},
1271 // where K is the third decoder derivative and H is the second.
1272 let Some(state) = self.hvp_state(target) else {
1273 return Array1::<f64>::zeros(v.len());
1274 };
1275 self.hvp_with_precomputed_state(&state, rho, v)
1276 }
1277
1278 /// PSD majorizer-vector product `B_GN(target; ρ) v` for the **nonconvex**
1279 /// isometry penalty.
1280 ///
1281 /// The Gauss-Newton block differentiates the normalized residual
1282 /// `R = g/gbar - G_ref` itself and returns `μ DRᵀ DR v`. This is PSD by
1283 /// construction and includes the shared-normalizer derivative exactly;
1284 /// using only `∂g` would reintroduce scale coupling and would not be the
1285 /// Gauss-Newton operator of the objective being minimized.
1286 fn psd_majorizer_hvp(
1287 &self,
1288 target: ArrayView1<'_, f64>,
1289 rho: ArrayView1<'_, f64>,
1290 v: ArrayView1<'_, f64>,
1291 ) -> Array1<f64> {
1292 let d = self
1293 .target
1294 .latent_dim
1295 .expect("IsometryPenalty requires latent_dim on its PsiSlice");
1296 let n_obs = target.len() / d;
1297 if !self.has_jacobian_cache("psd_majorizer_hvp")
1298 || !self.has_jacobian_second_source("psd_majorizer_hvp")
1299 {
1300 return Array1::<f64>::zeros(v.len());
1301 }
1302 let Some(jac2) = self.jacobian_second(target, n_obs, d) else {
1303 return Array1::<f64>::zeros(v.len());
1304 };
1305 let Some(g) = self.pullback_metric(d) else {
1306 return Array1::<f64>::zeros(v.len());
1307 };
1308 let Some(metric) = self.normalized_metric_state(g, n_obs, d) else {
1309 return Array1::<f64>::zeros(v.len());
1310 };
1311 let p = self.p_out;
1312 let mu = validated_learnable_weight(self.scalar_weight, rho[self.rho_index]);
1313 let mut out = Array1::<f64>::zeros(v.len());
1314 let mut wj_rows = Vec::with_capacity(n_obs);
1315 for n in 0..n_obs {
1316 let Some(wj) = self.weighted_jacobian_row(n, d) else {
1317 return Array1::<f64>::zeros(v.len());
1318 };
1319 wj_rows.push(wj);
1320 }
1321 let mut delta_g = Array2::<f64>::zeros((n_obs, d * d));
1322 for n in 0..n_obs {
1323 let row_delta = isometry_row_delta_g(jac2.view(), wj_rows[n].view(), v, n, d, p);
1324 for a in 0..d {
1325 for b in 0..d {
1326 delta_g[[n, a * d + b]] = row_delta[[a, b]];
1327 }
1328 }
1329 }
1330 let (delta_residual, _delta_normalizer) = metric.residual_direction(delta_g.view(), d);
1331 let mut g_dot_delta_residual = 0.0;
1332 for n in 0..n_obs {
1333 for k in 0..(d * d) {
1334 g_dot_delta_residual += metric.g[[n, k]] * delta_residual[[n, k]];
1335 }
1336 }
1337 let inv_norm = 1.0 / metric.normalizer;
1338 let inv_norm_sq = inv_norm * inv_norm;
1339 for n in 0..n_obs {
1340 let wj = &wj_rows[n];
1341 for c in 0..d {
1342 let mut trace_dg = 0.0;
1343 for a in 0..d {
1344 trace_dg += isometry_dg_entry(jac2.view(), wj.view(), n, d, p, a, a, c);
1345 }
1346 let delta_normalizer_c = trace_dg / metric.trace_denominator;
1347 let mut acc = -delta_normalizer_c * inv_norm_sq * g_dot_delta_residual;
1348 for a in 0..d {
1349 for b in 0..d {
1350 let dg = isometry_dg_entry(jac2.view(), wj.view(), n, d, p, a, b, c);
1351 acc += dg * inv_norm * delta_residual[[n, a * d + b]];
1352 }
1353 }
1354 out[n * d + c] = mu * acc;
1355 }
1356 }
1357 out
1358 }
1359
1360 fn grad_rho(&self, target: ArrayView1<'_, f64>, rho: ArrayView1<'_, f64>) -> Array1<f64> {
1361 // P(ρ) = ½ μ · S, where S is the (ρ-independent) Frobenius sum and
1362 // μ = exp(ρ_iso). So ∂P/∂ρ_iso = P.
1363 let mut out = Array1::<f64>::zeros(self.rho_count());
1364 out[self.rho_index] = self.value(target, rho);
1365 out
1366 }
1367
1368 fn rho_count(&self) -> usize {
1369 1
1370 }
1371
1372 fn name(&self) -> &str {
1373 "isometry"
1374 }
1375
1376 impl_scalar_apply_schedule!(scalar_weight);
1377}