gam_solve/reml/reml_outer_engine/penalty_coordinate.rs
1use super::*;
2pub use gam_problem::PenaltyCoordinate;
3
4/// Exact pseudo-logdeterminant log|S|₊ and its derivatives with respect to ρ.
5///
6/// # Exact pseudo-logdet on the positive eigenspace
7///
8/// For S(ρ) = Σ exp(ρ_k) S_k with S_k ⪰ 0, the nullspace
9/// N(S) = ∩_k N(S_k) is structurally fixed (independent of ρ).
10/// No eigenvalue of S crosses zero during optimization, so the
11/// pseudo-logdet L = Σ_{σ_i > 0} log σ_i is C∞ in ρ.
12///
13/// ## Computation
14///
15/// Eigendecompose S, identify positive eigenvalues σ_i > ε (where ε is a
16/// relative threshold for numerical zero detection), then:
17///
18/// L(S) = Σ_{positive} log σ_i
19/// ∂_k L = tr(S⁺ A_k) where A_k = λ_k S_k
20/// ∂²_kl L = δ_{kl} ∂_k L − tr(S⁺ A_l S⁺ A_k)
21///
22/// S⁺ is the Moore-Penrose pseudoinverse restricted to the positive
23/// eigenspace. These are the exact derivatives of L — no δ-regularization,
24/// no nullity metadata, no chain-rule inconsistencies.
25#[derive(Clone, Debug)]
26pub struct PenaltyLogdetDerivs {
27 /// L(S) = log|S|₊ — the exact pseudo-logdeterminant on the positive eigenspace.
28 ///
29 /// L(S) = Σ_{σ_i > ε} log σ_i, where ε is a relative threshold that
30 /// identifies the structural nullspace directly from the eigenspectrum.
31 pub value: f64,
32 /// ∂/∂ρₖ L(S) — first derivatives (one per smoothing parameter).
33 ///
34 /// ∂_k L = tr(S⁺ Aₖ) where Aₖ = λₖ Sₖ and S⁺ is the pseudoinverse
35 /// restricted to the positive eigenspace.
36 pub first: Array1<f64>,
37 /// ∂²/(∂ρₖ∂ρₗ) L(S) — second derivatives (for outer Hessian).
38 ///
39 /// ∂²_kl L = δ_{kl} ∂_k L − λₖ λₗ tr(S⁺ Sₖ S⁺ Sₗ).
40 pub second: Option<Array2<f64>>,
41}
42
43/// Unified representation of a single smoothing-parameter penalty coordinate.
44///
45
46// PenaltyLogdetEigenspace, build_penalty_logdet_eigenspace,
47// scaled_penalty_logdet_nullspace_leakage, and frobenius_inner_same_shape
48// have been replaced by the canonical PenaltyPseudologdet in
49// super::super::penalty_logdet. All callers now use that module directly.
50
51/// Reduced trace kernel `K = U · M · Uᵀ` for pseudo-logdet REML/LAML
52/// criteria: an orthonormal column basis `u_s` (p × r) plus the r × r
53/// symmetric reduced kernel `h_proj_inverse`, with `tr(K · A)` evaluated as
54/// `tr(M · Uᵀ A U)` so contractions run on the r-dimensional subspace.
55///
56/// Two producers install it, with different (documented) exactness domains:
57///
58/// 1. **Intrinsic spectral form (#901, the GLM dense paths in runtime.rs —
59/// `intrinsic_hessian_pseudo_logdet_parts`):** `u_s = U_H`, the kept
60/// eigenvectors of the penalized Hessian `H_pen`, and `h_proj_inverse =
61/// diag(1/σ_a)`. Then `K = H_pen⁺` exactly, and `tr(K · Ḣ)` is the exact
62/// first derivative of the cost's `log|H_pen|₊` along **every** drift
63/// direction — penalty-supported or not, moving-subspace ψ drifts
64/// included — because on a constant-rank stratum first-order eigenvector
65/// motion cancels out of the pseudo-logdet derivative. This object can be
66/// traced against the GLM IFT correction `D_β H[v] = X' diag(c ⊙ X v) X`
67/// (which leaks onto `null(S)` via the intercept column) without error.
68///
69/// 2. **Range(Sλ) Schur block (#752, `joint_penalty_subspace_trace_parts`
70/// in custom_family.rs):** `u_s` spans `range(Sλ)` and `h_proj_inverse =
71/// U_Sᵀ (H+Sλ)⁺ U_S`. For penalty-supported `A` (`A = ∂Sλ/∂ρ`), the
72/// identity `U_S U_Sᵀ A U_S U_Sᵀ = A` gives `tr(K · A) = tr((H+Sλ)⁺ A) =
73/// d log|H+Sλ|₊/dρ` — exact for the ρ family. It is **not** exact for
74/// drifts with `null(Sλ)` support (GLM cubic corrections, ψ basis
75/// drifts); paths that carry such drifts must install form 1.
76///
77/// Historically this struct carried a third reading — `(U_Sᵀ H U_S)⁻¹`, the
78/// plain projected inverse paired with the projected cost `log|U_Sᵀ H U_S|₊`.
79/// That object is WRONG as a REML determinant term: splitting `H` over
80/// `range(S) ⊕ ker(S)` as `[[A,B],[Bᵀ,C]]`, the projected logdet is
81/// `log det A`, dropping the θ-dependent Schur curvature
82/// `log det(C − BᵀA⁻¹B)` of the likelihood-identified, penalty-null block
83/// (sign-flipped ρ-gradients, ~1e5 ψ blow-ups vs FD — #901). No producer
84/// builds it anymore.
85#[derive(Clone, Debug)]
86pub struct PenaltySubspaceTrace {
87 pub u_s: Array2<f64>,
88 pub h_proj_inverse: Array2<f64>,
89 /// The additive scalar that turns the operator's own `logdet()` into the
90 /// pseudo-log-determinant THIS kernel is the derivative kernel of (#2765).
91 ///
92 /// Both producers compute the corrected value and the kernel from one
93 /// eigendecomposition of one matrix, and both used to hand them back as two
94 /// unrelated things: the scalar into `InnerSolution::hessian_logdet_
95 /// correction` (whose documented meaning is a UNIFORM RESCALE `−p·log s`,
96 /// nothing else) and the kernel into `penalty_subspace_trace`. Two fields
97 /// travelling separately can be separated, and the tangent-projection entry
98 /// separated them: it drops the kernel (correctly — a p-space subspace
99 /// kernel does not act on an m-dimensional face) while KEEPING the scalar,
100 /// rescaled by the rank ratio `m/p` as though it were the uniform rescale it
101 /// is not. The criterion's value then carried a θ-varying term with no
102 /// kernel anywhere to differentiate it, and the outer gradient was short by
103 /// exactly that term's derivative — measured on the #2765 fixture as the
104 /// DOMINANT half of the `logdet_h` disagreement on every θ coordinate.
105 ///
106 /// Carrying the scalar HERE makes the pairing structural: a lane that drops
107 /// the kernel drops the correction with it, because they are one object.
108 pub logdet_correction: f64,
109}
110
111impl PenaltySubspaceTrace {
112 /// Compute `tr(K · A)` where `K = U_S · h_proj_inverse · U_Sᵀ` — the
113 /// pseudo-logdet trace kernel (see the struct doc for the two producer
114 /// forms and their exactness domains).
115 ///
116 /// Uses the identity `tr(K · A) = tr(h_proj_inverse · U_Sᵀ A U_S)` so the
117 /// reduction runs on the r × r subspace rather than materializing K.
118 pub fn trace_projected_logdet(&self, a: &Array2<f64>) -> f64 {
119 gam_terms::construction::trace_penalty_covariance_in_orthogonal_basis(
120 a,
121 &self.u_s,
122 &self.h_proj_inverse,
123 )
124 }
125
126 /// Reduce a p × p matrix `A` to its r × r projection `U_Sᵀ · A · U_S`.
127 ///
128 /// Exposed so callers that need the same reduced matrix for both the
129 /// single-trace `tr(K · A)` and the cross-trace `tr(K · A · K · B)`
130 /// can avoid repeating the p × p · p × r matmuls. Routes through
131 /// faer's parallel SIMD GEMM (`fast_atb` / `fast_ab`) so the p-large
132 /// contraction axis amortizes across all cores.
133 pub fn reduce(&self, a: &Array2<f64>) -> Array2<f64> {
134 let u_s_t_a = gam_linalg::faer_ndarray::fast_atb(&self.u_s, a);
135 gam_linalg::faer_ndarray::fast_ab(&u_s_t_a, &self.u_s)
136 }
137
138 /// Compute `tr(H_proj⁻¹ · R)` given an already-reduced `R = U_Sᵀ A U_S`.
139 pub fn trace_projected_logdet_reduced(&self, r_mat: &Array2<f64>) -> f64 {
140 gam_terms::construction::trace_reduced_penalty_covariance(r_mat, &self.h_proj_inverse)
141 }
142
143 /// Cross-trace given pre-reduced blocks `R_A = U_Sᵀ A U_S`, `R_B = U_Sᵀ B U_S`.
144 pub fn trace_projected_logdet_cross_reduced(&self, ra: &Array2<f64>, rb: &Array2<f64>) -> f64 {
145 // left = H_proj⁻¹ · R_A ; right = H_proj⁻¹ · R_B ; tr(left · right).
146 let left = self.h_proj_inverse.dot(ra);
147 let right = self.h_proj_inverse.dot(rb);
148 dense::trace_product(&left, &right)
149 }
150
151 /// Reduce a `HyperOperator` `A` to its `r × r` projection
152 /// `U_Sᵀ · A · U_S` without materializing the dense `p × p` block.
153 /// Uses `A.mul_mat(U_S)` so an Hv-only operator is probed in `r` matvecs
154 /// (each `O(work_of_A)`), then a single `r × p × r` reduction routed
155 /// through faer's parallel SIMD GEMM (`fast_atb`).
156 pub fn reduce_operator<O>(&self, a: &O) -> Array2<f64>
157 where
158 O: HyperOperator + ?Sized,
159 {
160 let au = a.mul_mat(&self.u_s);
161 gam_linalg::faer_ndarray::fast_atb(&self.u_s, &au)
162 }
163
164 /// `tr(K · A)` for `A` exposed only as a `HyperOperator`. Mirrors
165 /// [`Self::trace_projected_logdet`] without forcing dense materialization
166 /// of `A`.
167 pub fn trace_operator<O>(&self, a: &O) -> f64
168 where
169 O: HyperOperator + ?Sized,
170 {
171 self.trace_projected_logdet_reduced(&self.reduce_operator(a))
172 }
173
174 /// Projected leverage `h^{G,proj}_i = Xᵢᵀ · K · Xᵢ` for every row of `x`.
175 ///
176 /// Computed in bulk as `Z = X · U_S` (`n × r`) then
177 /// `h^{G,proj}_i = (Z H_proj⁻¹ Zᵀ)_{ii} = Σ_{a,b} Z_{ia} (H_proj⁻¹)_{ab} Z_{ib}`,
178 /// total cost `O(n · p · r + n · r²)` — strictly cheaper than `n` calls
179 /// to `Self::apply` because the `n × p · p × r` GEMM streams the
180 /// `p`-axis once. Streams `X` through `try_row_chunk` so operator-backed
181 /// (Lazy) designs at large scale never densify the full `(n × p)` block.
182 pub fn xt_projected_kernel_x_diagonal(&self, x: &DesignMatrix) -> Array1<f64> {
183 let n = x.nrows();
184 let p = x.ncols();
185 let r = self.u_s.ncols();
186 assert_eq!(self.u_s.nrows(), p);
187 assert_eq!(self.h_proj_inverse.nrows(), r);
188 assert_eq!(self.h_proj_inverse.ncols(), r);
189
190 let block = {
191 const TARGET_CHUNK_FLOATS: usize = 1 << 16;
192 (TARGET_CHUNK_FLOATS / p.max(1)).clamp(1, n.max(1))
193 };
194
195 let mut h = Array1::<f64>::zeros(n);
196 let mut start = 0usize;
197 while start < n {
198 let end = (start + block).min(n);
199 let rows = x.try_row_chunk(start..end).unwrap_or_else(|err| {
200 // SAFETY: `start..end` is constructed from
201 // `0..n = 0..x.nrows()` with `end = (start+block).min(n)`,
202 // so it is always a valid sub-range of `x`. Failure means
203 // the operator broke its row-chunk contract.
204 // SAFETY: row range built from 0..x.nrows(); failure means operator broke its contract.
205 reml_contract_panic(format!(
206 "xt_projected_kernel_x_diagonal: row chunk failed: {err}"
207 ))
208 });
209 // Z_chunk = rows · U_S ((end-start) × r).
210 let z_chunk = gam_linalg::faer_ndarray::fast_ab(&rows, &self.u_s);
211 // h_i = Σ_{a,b} Z_{ia} (H_proj⁻¹)_{ab} Z_{ib}.
212 for (i, row_z) in z_chunk.outer_iter().enumerate() {
213 let mut acc = 0.0;
214 for (z_a, h_row) in row_z
215 .iter()
216 .copied()
217 .zip(self.h_proj_inverse.rows().into_iter())
218 {
219 let mut inner = 0.0;
220 for (h_value, z_b) in h_row.iter().copied().zip(row_z.iter().copied()) {
221 inner += h_value * z_b;
222 }
223 acc += z_a * inner;
224 }
225 h[start + i] = acc;
226 }
227 start = end;
228 }
229 h
230 }
231
232 /// Projected bilinear pseudo-inverse `aᵀ · K⁺ · b` where
233 /// `K⁺ = U_S · H_proj⁻¹ · U_Sᵀ`.
234 ///
235 /// Used by the rank-deficient LAML IFT correction path: when `b ∈
236 /// col(S_k) ⊂ range(S_+)`, applying the projected pseudo-inverse
237 /// instead of the full `H⁻¹` strips spurious null-space noise from
238 /// `a` (≈ the outer-stationarity residual `r`) before the inverse,
239 /// without biasing the numerator. Costs `O(p·r + r²)` versus the
240 /// `O(p²·r)` full solve.
241 pub fn bilinear_pseudo_inverse(&self, a: &Array1<f64>, b: &Array1<f64>) -> f64 {
242 let proj_a = gam_linalg::faer_ndarray::fast_atv(&self.u_s, a);
243 let proj_b = gam_linalg::faer_ndarray::fast_atv(&self.u_s, b);
244 let h_proj_inv_b = self.h_proj_inverse.dot(&proj_b);
245 proj_a.dot(&h_proj_inv_b)
246 }
247
248 /// Euclidean projection onto the retained penalty/Hessian range used by
249 /// this projected kernel: `P_S a = U_S U_Sᵀ a`.
250 pub fn project_onto_subspace(&self, a: &Array1<f64>) -> Array1<f64> {
251 let proj_a = gam_linalg::faer_ndarray::fast_atv(&self.u_s, a);
252 gam_linalg::faer_ndarray::fast_av(&self.u_s, &proj_a)
253 }
254
255 /// Apply the projected pseudo-inverse `K = U_S · H_proj⁻¹ · U_Sᵀ` to a
256 /// vector `a`, returning the minimum-norm solution `v = K · a` of the
257 /// system `H v = a` restricted to `range(S₊)`.
258 ///
259 /// This is the correct stand-in for `H⁻¹ · a` in all per-coordinate
260 /// outer-gradient/Hessian formulas when the rank-deficient LAML fix is
261 /// active (`penalty_subspace_trace = Some`). The full `H⁻¹ · a` solve
262 /// amplifies any component of `a` outside `range(H_free)` by
263 /// `1/σ_min(H_active_normal)` — which on large-scale survival
264 /// marginal-slope is ~10¹² and propagates into outer gradients of
265 /// magnitude 10¹⁴, suppressed by the envelope tripwire downstream and
266 /// killing every seed before the fit can take a step. Dropping the
267 /// `ker(M)` component of `a` is exactly what the cost `½ log|M|₊` demands —
268 /// that subspace is the cost's gauge, so the drop is bit-invariant for the
269 /// correction regardless of the residual's gauge-direction magnitude;
270 /// `ProjectedKktResidual::projected_into_reduced_range` certifies only that
271 /// the dropped component is genuinely invisible to this kernel (no retained-
272 /// range leakage / orthonormal `U_S`) before the IFT correction uses it. The
273 /// returned gradient lives on the identifiable manifold, matching the
274 /// projected `½ log|M|₊` term.
275 ///
276 /// Costs `O(p·r + r²)` for the two `U_S`-contractions plus the `r × r`
277 /// solve — strictly cheaper than the `O(p²)` full `hop.solve_multi`
278 /// when `r ≪ p`, and bounded regardless of `σ_min(H)`.
279 pub fn apply_pseudo_inverse(&self, a: &Array1<f64>) -> Array1<f64> {
280 // The one sensitivity operator (#935): the projected inverse action
281 // `U_S · H_proj⁻¹ · U_Sᵀ · a` has a single spelling, shared with every
282 // other consumer of `FittedInverse::Projected`.
283 self.sensitivity().apply(a)
284 }
285
286 /// View this projected trace kernel as the unified `FitSensitivity`
287 /// (#935) over the rank-deficient LAML convention `K = U_S · H_proj⁻¹ ·
288 /// U_Sᵀ`. The trace machinery stays here; the *inverse action* is the
289 /// shared operator, so no site can disagree about what `H⁻¹` means.
290 pub fn sensitivity(&self) -> crate::sensitivity::FitSensitivity<'_> {
291 crate::sensitivity::FitSensitivity::from_projected(&self.u_s, &self.h_proj_inverse)
292 }
293
294 /// Build the **constrained pseudo-inverse kernel**
295 /// `K_T = K_S − K_S Aᵀ (A K_S Aᵀ)⁻¹ A K_S`
296 /// from this penalty-projected kernel `K_S` and the *active* row block
297 /// `A_act` of the joint linear inequality constraint matrix.
298 ///
299 /// `K_T` is the **Moore-Penrose pseudo-inverse of `H` restricted to
300 /// `T = range(S₊) ∩ ker(A_act)`** — the smooth manifold the inner
301 /// solver actually moves on at a constrained-stationary point. It is
302 /// exactly the kernel that solves the per-coordinate saddle-point
303 /// IFT system
304 ///
305 /// ```text
306 /// [ H Aᵀ_act ] [ ∂β/∂ρ_k ] [ −a_k ]
307 /// [ A_act 0 ] [ ∂λ/∂ρ_k ] = [ 0 ]
308 /// ```
309 ///
310 /// with `∂β/∂ρ_k = −K_T · a_k`. Using `K_T` for the per-coordinate
311 /// mode response `v_k` makes the outer gradient the *exact* derivative
312 /// of the projected Laplace cost `log|U_Tᵀ H U_T|`, where `U_T` is an
313 /// orthonormal basis of `T` — the marginal-likelihood determinant the
314 /// inner is actually drawing on.
315 ///
316 /// Returns a [`ConstrainedSubspaceKernel`] handle that caches the
317 /// small `k_active × k_active` Schur complement so subsequent
318 /// `apply_pseudo_inverse` calls for different RHS reuse it. When the
319 /// active set is empty the handle degrades to a pass-through over
320 /// `self` (no extra work).
321 ///
322 /// Total precompute cost: `k_active` calls to
323 /// [`Self::apply_pseudo_inverse`] (one per active row) plus a
324 /// `k_active × k_active` Cholesky/QR. Per-vector `apply` cost: one
325 /// `K_S` apply + one `k_active × p` matvec + one small triangular
326 /// solve + one `p × k_active` matvec.
327 pub fn with_active_constraints<'a>(
328 &'a self,
329 a_act: ndarray::ArrayView2<'a, f64>,
330 ) -> ConstrainedSubspaceKernel<'a> {
331 let k_active = a_act.nrows();
332 if k_active == 0 {
333 return ConstrainedSubspaceKernel {
334 kernel: self,
335 z: Array2::zeros((0, self.u_s.nrows())),
336 a_act,
337 m_inv: Array2::zeros((0, 0)),
338 k_active: 0,
339 };
340 }
341 // Z = K_S · Aᵀ_act, shape (p × k_active).
342 let p = self.u_s.nrows();
343 let mut z = Array2::<f64>::zeros((p, k_active));
344 for j in 0..k_active {
345 let a_row = a_act.row(j).to_owned();
346 let k_s_a_row = self.apply_pseudo_inverse(&a_row);
347 z.column_mut(j).assign(&k_s_a_row);
348 }
349 // M = A_act · Z (shape k_active × k_active, symmetric PSD on
350 // range(K_S) ∩ image(A_actᵀ); on a rank-deficient overlap we
351 // add a tiny diagonal regulariser so the inversion remains
352 // bounded — same noise-floor strategy as elsewhere in this
353 // module).
354 let mut m = a_act.dot(&z);
355 // Symmetrise (numerical noise from the matmul leaves small skew).
356 for i in 0..k_active {
357 for j in 0..i {
358 let avg = 0.5 * (m[[i, j]] + m[[j, i]]);
359 m[[i, j]] = avg;
360 m[[j, i]] = avg;
361 }
362 }
363 // Eigendecomposition-based Moore-Penrose pseudo-inverse with a
364 // relative spectral cutoff. This is the principled treatment of
365 // rank deficiency in `A_act` when restricted to `range(S₊)`:
366 // some active constraint rows may be linearly dependent after
367 // projection (e.g. several monotonicity rows pinning the same
368 // flat region all reduce to the same row in `range(S₊)`).
369 // A plain `M⁻¹` then amplifies near-null directions; the
370 // pseudo-inverse drops them at a relative threshold
371 // `tol = eps · k_active · σ_max(M)`, which is the standard
372 // NumPy/LAPACK convention and exactly what Codex flagged as
373 // necessary in the math review.
374 let (evals, evecs) = m.eigh(faer::Side::Lower).unwrap_or_else(|err| {
375 log::debug!(
376 "penalty coordinate: {k_active}x{k_active} eigendecomposition failed ({err}); \
377 falling back to a zero spectrum in the identity basis"
378 );
379 (Array1::<f64>::zeros(k_active), Array2::<f64>::eye(k_active))
380 });
381 let sigma_max = evals.iter().copied().fold(0.0_f64, f64::max).max(0.0);
382 let tol = f64::EPSILON * (k_active as f64) * sigma_max.max(1.0);
383 let mut m_inv = Array2::<f64>::zeros((k_active, k_active));
384 let mut dropped = 0usize;
385 for q in 0..k_active {
386 if evals[q] > tol {
387 let inv_sigma = 1.0 / evals[q];
388 // Outer product u_q u_qᵀ scaled by 1/σ_q.
389 for i in 0..k_active {
390 for j in 0..k_active {
391 m_inv[[i, j]] += inv_sigma * evecs[[i, q]] * evecs[[j, q]];
392 }
393 }
394 } else {
395 dropped += 1;
396 }
397 }
398 if dropped > 0 {
399 log::debug!(
400 "[constrained-subspace kernel] dropped {} of {} active-constraint directions \
401 (rank-deficient on range(S₊)); pseudo-inverse threshold = {:.3e}",
402 dropped,
403 k_active,
404 tol,
405 );
406 }
407 ConstrainedSubspaceKernel {
408 kernel: self,
409 z,
410 a_act,
411 m_inv,
412 k_active,
413 }
414 }
415}
416
417/// Per-evaluation handle that combines a penalty-projected
418/// [`PenaltySubspaceTrace`] with an active inequality-constraint block,
419/// producing the constraint-aware pseudo-inverse
420/// `K_T = K_S − K_S Aᵀ (A K_S Aᵀ)⁻¹ A K_S`. See
421/// [`PenaltySubspaceTrace::with_active_constraints`] for the math.
422///
423/// Caches the small `k_active × k_active` Schur inverse so subsequent
424/// per-coordinate `apply` calls only do `O(p · k_active)` work each.
425pub struct ConstrainedSubspaceKernel<'a> {
426 pub(crate) kernel: &'a PenaltySubspaceTrace,
427 /// `Z = K_S · Aᵀ_act`, shape `(p × k_active)`.
428 pub(crate) z: Array2<f64>,
429 /// Active-row block of the joint constraint matrix.
430 pub(crate) a_act: ndarray::ArrayView2<'a, f64>,
431 /// `(A_act · K_S · Aᵀ_act)⁻¹`, shape `(k_active × k_active)`.
432 pub(crate) m_inv: Array2<f64>,
433 pub(crate) k_active: usize,
434}
435
436impl<'a> ConstrainedSubspaceKernel<'a> {
437 /// Apply `K_T = K_S − K_S Aᵀ (A K_S Aᵀ)⁻¹ A K_S` to `a`. The result
438 /// lies in `range(S₊) ∩ ker(A_act)` — the smooth manifold the inner
439 /// solver actually moves on at a constrained-stationary point.
440 pub fn apply_pseudo_inverse(&self, a: &Array1<f64>) -> Array1<f64> {
441 let v_s = self.kernel.apply_pseudo_inverse(a);
442 if self.k_active == 0 {
443 return v_s;
444 }
445 // mu = M_inv · (A_act · v_s)
446 let t = self.a_act.dot(&v_s);
447 let mu = self.m_inv.dot(&t);
448 // v = v_s - Z · mu
449 let correction = self.z.dot(&mu);
450 v_s - &correction
451 }
452
453 /// Whether any active constraints contribute (when false this kernel
454 /// is identical to the bare [`PenaltySubspaceTrace::apply_pseudo_inverse`]).
455 pub fn has_active_constraints(&self) -> bool {
456 self.k_active > 0
457 }
458}
459
460/// Tangency self-audit gate for the constrained mode-response arm: the
461/// emitted `v = K_T · rhs` must lie in `ker(A_act)` by construction, so
462/// `|A_act · v|` is compared against this fraction of the cancellation
463/// scale `|A_act| · |v|` (per active row). Generous enough that legitimate
464/// rank-deficient active sets (whose dropped Schur directions leave
465/// ε-level residue, see [`PenaltySubspaceTrace::with_active_constraints`])
466/// never trip it; the historical failure mode it guards (the d6b17a7f
467/// `1/σ_min ≈ 10¹²` null-space amplification) exceeds it by six orders.
468pub(crate) const THETA_MODE_RESPONSE_TANGENCY_GATE: f64 = 1e-6;
469
470/// #931 migration pass 2 — the ThetaDirection shared-drift pass: the ONE
471/// per-evaluation selection of the IFT mode-response kernel behind every
472/// `dβ̂/dθ = −K · ∂g/∂θ` solve in the outer gradient/Hessian assembly.
473///
474/// Before this object existed, four sites (the gradient solve stack in
475/// `reml_laml_evaluate`, the ρ- and ext-coordinate standalone fallbacks in
476/// `compute_outer_hessian`, and the standalone fallback in
477/// `build_outer_hessian_operator`) each re-implemented the same selection
478/// rule by hand, with comments warning each other to "mirror the
479/// selection exactly, otherwise the operator-form Hessian and dense
480/// materialization disagree on every entry". A hand-copied convention every
481/// caller must remember is precisely the objective↔gradient desync surface
482/// (#748/#752/#901 class) the criterion-as-atoms architecture (#931)
483/// removes. Now the rule is DECIDED in exactly one constructor and every
484/// consumer is a contraction of the same kernel object — the gradient and
485/// both Hessian representations structurally cannot pick different
486/// inverses for the same evaluation point:
487///
488/// * Active inequality constraints recorded on the inner solution → the
489/// lifted constrained kernel
490/// `K_T = K_S − K_S Aᵀ (A K_S Aᵀ)⁻¹ A K_S`. The inner SCOP solver
491/// clamps β̂(θ) onto `T = range(S₊) ∩ ker(A_act)`, so the true IFT
492/// derivative lives in T and the lifted kernel gives the minimum-norm
493/// solution there; the full solve would amplify any RHS component
494/// outside `range(H_free)` by `1/σ_min(H_active_normal)` — ~10¹² on
495/// large-scale survival marginal-slope (commit d6b17a7f).
496/// * Otherwise → the FULL Hessian solve `v = H⁻¹ · rhs`, even when the
497/// LAML cost surface uses the projected logdet `½ log|U_Sᵀ H U_S|`:
498/// the inner solver converges β̂ ∈ R^p in the unconstrained full
499/// space, so the IFT identity demands the full inverse, and the
500/// penalty-subspace projection acts on the TRACE contraction side
501/// only. Routing through bare `K_S` here would discard the
502/// `null(S₊)` component of dβ̂/dθ — the near-separable ψ-gradient
503/// blow-up pinned by `duchon_probit_per_row_dnu_dpsi_fd_vs_analytic`.
504///
505/// The two emission shapes (`respond_one` per-vector, `respond_stack`
506/// batched) exist because the call sites have different RHS layouts and
507/// their solve shapes must stay bit-identical to the pre-port assembly
508/// (per-column GEMV vs blocked GEMM sum in different orders) — NOT because
509/// a site may choose a different kernel. Both shapes dispatch on the same
510/// stored decision.
511///
512/// This is the `Sensitivity`-operator half of the `ThetaDirection`
513/// calculus sketched in `atoms.rs`: the direction's `β̇` channel is a
514/// contraction of this kernel, so atoms borrowing the shared drift can no
515/// longer see a different chain rule than their neighbors.
516pub(crate) struct ThetaModeResponseKernel<'s> {
517 pub(crate) hop: &'s dyn HessianFactorization,
518 /// `Some` exactly when the selection rule chose the lifted constrained
519 /// kernel. Built once per evaluation point (one Schur-complement
520 /// factorization), shared by every gradient/Hessian consumer — the
521 /// pre-port code rebuilt it per consumer site.
522 pub(crate) constrained: Option<ConstrainedSubspaceKernel<'s>>,
523}
524
525impl<'s> ThetaModeResponseKernel<'s> {
526 /// The ONE place the mode-response kernel selection rule lives.
527 pub(crate) fn select(
528 subspace: Option<&'s PenaltySubspaceTrace>,
529 active_constraints: Option<&'s ActiveLinearConstraintBlock>,
530 hop: &'s dyn HessianFactorization,
531 ) -> Self {
532 let constrained = match (subspace, active_constraints) {
533 (Some(kernel), Some(block)) => {
534 let ck = kernel.with_active_constraints(block.a.view());
535 ck.has_active_constraints().then_some(ck)
536 }
537 _ => None,
538 };
539 Self { hop, constrained }
540 }
541
542 /// Mode response for one right-hand side: `K_T · rhs` under active
543 /// constraints, `H⁻¹ · rhs` (single-RHS `solve`) otherwise. Used by the
544 /// per-coordinate fallbacks whose pre-port assembly solved one vector at
545 /// a time — the single-RHS shape is preserved bit-identically.
546 pub(crate) fn respond_one(&self, rhs: &Array1<f64>) -> Array1<f64> {
547 match self.constrained.as_ref() {
548 Some(ck) => {
549 let v = ck.apply_pseudo_inverse(rhs);
550 self.certify_tangency(ck, &v);
551 v
552 }
553 None => self.hop.solve(rhs),
554 }
555 }
556
557 /// Mode responses for a column-stacked RHS block: per-column `K_T`
558 /// applies under active constraints (the lifted kernel has no blocked
559 /// form), one batched `solve_multi` otherwise (BLAS-3 / GPU batched
560 /// route) — exactly the shapes the stacked call sites used pre-port.
561 /// Zero RHS columns (box-masked ρ coordinates) emit exact zeros through
562 /// either arm, since both kernels are linear.
563 pub(crate) fn respond_stack(&self, rhs_stack: &Array2<f64>) -> Array2<f64> {
564 match self.constrained.as_ref() {
565 Some(ck) => {
566 let mut out = Array2::<f64>::zeros(rhs_stack.raw_dim());
567 for (j, col) in rhs_stack.columns().into_iter().enumerate() {
568 let v = ck.apply_pseudo_inverse(&col.to_owned());
569 self.certify_tangency(ck, &v);
570 out.column_mut(j).assign(&v);
571 }
572 out
573 }
574 None => self.hop.solve_multi(rhs_stack),
575 }
576 }
577
578 /// Per-atom certify body (#934 FD-self-audit pattern, applied as an
579 /// exact structural invariant): every constrained emission must lie in
580 /// `ker(A_act)` — `A_act · v = 0` is the defining property of `K_T`'s
581 /// range, so a violation can only mean the kernel object and the
582 /// emission desynced. Checked on every constrained response (cost
583 /// `O(k_active · p)`, negligible next to the apply itself) against the
584 /// row-wise cancellation scale `|A_act| · |v|`; a violation does not
585 /// fail the fit — it names the atom loudly in the `[CERTIFICATE]`
586 /// stream, exactly like the outer-optimum criterion audit. The
587 /// unconstrained arm carries no separate certify: its coherence with
588 /// the criterion VALUE is audited end-to-end by the #934
589 /// `OuterCriterionCertificate` at every returned optimum.
590 pub(crate) fn certify_tangency(&self, ck: &ConstrainedSubspaceKernel<'_>, v: &Array1<f64>) {
591 let residual = ck.a_act.dot(v);
592 for (row, r) in residual.iter().enumerate() {
593 let scale: f64 = ck
594 .a_act
595 .row(row)
596 .iter()
597 .zip(v.iter())
598 .map(|(a, x)| (a * x).abs())
599 .sum();
600 if r.abs() > THETA_MODE_RESPONSE_TANGENCY_GATE * (scale + f64::EPSILON) {
601 log::warn!(
602 "[CERTIFICATE warning] atom \"theta_mode_response\": constrained IFT \
603 mode response left ker(A_act) — active row {row} residual {:.3e} \
604 exceeds gate {:.1e}·{:.3e}; the lifted kernel K_T and its emission \
605 have desynced (#931 pass-2 invariant)",
606 r.abs(),
607 THETA_MODE_RESPONSE_TANGENCY_GATE,
608 scale,
609 );
610 }
611 }
612 }
613}
614
615impl ProjectedKktResidual {
616 pub(crate) fn projected_into_reduced_range(
617 &self,
618 kernel: &PenaltySubspaceTrace,
619 ) -> Result<Self, String> {
620 match self.subspace {
621 KktResidualSubspace::ReducedRange => Ok(self.clone()),
622 KktResidualSubspace::ActiveProjected => {
623 let reduced_residual = kernel.project_onto_subspace(&self.residual);
624 let dropped = &self.residual - &reduced_residual;
625 // Validity invariant for reducing `r_A → r_R = U_S U_Sᵀ r_A`
626 // before the projected IFT correction `−½ rᵀ K r`, `K = U_S
627 // H_proj⁻¹ U_Sᵀ`.
628 //
629 // Since #901 the kernel's `U_S` is the ORTHONORMAL eigenbasis of
630 // the full LAML Hessian `M = H + Sλ (+ H_Φ)` over its identifiable
631 // range (`|σ| > positive_eigenvalue_threshold`), the SAME kept-set
632 // that defines the cost's pseudo-logdet `½ log|M|₊`
633 // (`joint_penalty_subspace_trace_parts`). The dropped component
634 // `r_A − r_R = (I − U_S U_Sᵀ) r_A` therefore lives in `ker(M)` —
635 // the genuine gauge of the cost surface — and is ANNIHILATED by the
636 // kernel: `K (r_A − r_R) = U_S H_proj⁻¹ (U_Sᵀ(I − U_S U_Sᵀ) r_A) = 0`
637 // because `U_Sᵀ(I − U_S U_Sᵀ) = 0` for orthonormal `U_S`. Dropping
638 // it leaves the correction `−½ rᵀ K r` (and every `tr(K·)` trace)
639 // bit-identical, so a gauge-direction residual of ANY magnitude is
640 // valid to reduce.
641 //
642 // The magnitude the inner solver leaves in `ker(M)` is NOT bounded
643 // by the KKT stationarity tolerance: those are the weakly-/un-
644 // identified directions where `M`'s curvature is below threshold, so
645 // a Newton step to resolve their residual is `r/σ` — far outside the
646 // trust region — and the inner correctly leaves them unmoved
647 // (gam#1040 gauge/plateau exit), the outer projected pseudo-inverse
648 // correctly discards them, and the cost `½ log|M|₊` never included
649 // them. Comparing that gauge mass to `residual_tol` (the previous
650 // gate) is a category error inherited from the pre-#901 `range(Sλ)`
651 // kernel, whose dropped subspace `null(Sλ)` DID contain likelihood-
652 // identified curvature; it rejected every oversmoothed marginal-slope
653 // fit as a false "unresolved mass" contract violation.
654 //
655 // The one property that IS required — and that this reduction must
656 // certify — is that the dropped component is genuinely invisible to
657 // the kernel, i.e. carries no RETAINED-range mass. For an orthonormal
658 // `U_S` the leakage `U_Sᵀ(r_A − r_R)` is identically zero; a nonzero
659 // value can only mean `U_S` is not orthonormal / the kernel and
660 // residual desynced (the actual failure class the guard exists to
661 // catch), in which case reducing WOULD alter the correction.
662 let retained_leak = gam_linalg::faer_ndarray::fast_atv(&kernel.u_s, &dropped);
663 let leak_inf = retained_leak
664 .iter()
665 .map(|value| value.abs())
666 .fold(0.0_f64, f64::max);
667 let residual_inf = self
668 .residual
669 .iter()
670 .map(|value| value.abs())
671 .fold(0.0_f64, f64::max);
672 // Orthonormal-projection round-off floor: `U_Sᵀ(I − U_S U_Sᵀ) r`
673 // accumulates only `O(r_cols · ε · ‖r‖)` numerical noise, so a
674 // relative floor of `1e-8·(1 + ‖r‖∞)` clears every well-formed
675 // kernel by orders of magnitude while a genuinely non-orthonormal
676 // `U_S` leaks `O(‖r‖)` and trips it.
677 let leak_floor = 1e-8 * (1.0 + residual_inf);
678 if leak_inf > leak_floor {
679 return Err(format!(
680 "projected KKT residual reduction leaks retained-range mass: \
681 |U_Sᵀ(r_A − r_R)|∞={leak_inf:.3e} > floor={leak_floor:.3e}; the \
682 reduced-range kernel U_S is not orthonormal or is desynced from the \
683 residual, so projecting r_A onto range(U_S) would change the IFT \
684 correction −½ rᵀ U_S H_proj⁻¹ U_Sᵀ r instead of leaving it invariant"
685 ));
686 }
687 let mut reduced = Self::from_reduced_range(reduced_residual);
688 reduced.residual_tol = self.residual_tol;
689 reduced.free_rank = self.free_rank;
690 Ok(reduced)
691 }
692 }
693 }
694
695 /// The KKT-stationarity tolerance the inner solver applied at the
696 /// producing iterate. Returns `None` when the residual was built
697 /// from a legacy site that hasn't been threaded yet; downstream
698 /// consumers should substitute `f64::NAN` in that case.
699 pub fn residual_tol(&self) -> Option<f64> {
700 self.residual_tol
701 }
702
703}