Skip to main content

gam_models/transformation_normal/
custom_family.rs

1use super::*;
2use gam_problem::{ConstraintSet, KhatriRaoConeConstraints};
3
4// ---------------------------------------------------------------------------
5// CustomFamily implementation
6// ---------------------------------------------------------------------------
7
8impl CustomFamily for TransformationNormalFamily {
9    /// The direct-α chart makes `h'` affine in β, so the change-of-variables
10    /// Jacobian `−log h'` is a canonical self-concordant barrier (the `−½h²`
11    /// Gaussian kernel and the quadratic penalty are trivially self-concordant).
12    /// The inner Newton may therefore globalize with the self-concordant damped
13    /// step, which converges to the tiny-positive-`h'` interior optimum instead
14    /// of the trust-region metric's `1/h'²` linear-rate crawl (gam#979).
15    ///
16    /// The one non-strictly-self-concordant term is the finite-support endpoint
17    /// normalizer `−log(Φ(h_U) − Φ(h_L))`: it is convex (log-concave Gaussian
18    /// measure, Prékopa) but its truncated-Gaussian third cumulant is not
19    /// SC-bounded on a tight support. The damped step therefore keys the
20    /// decrement on the barrier and carries the endpoint term through the
21    /// existing acceptance ratio — the barrier is the term whose `1/h'²`
22    /// curvature stalls the crawl, so flagging the family self-concordant is the
23    /// correct trigger for that step.
24    fn inner_objective_is_self_concordant(&self) -> bool {
25        true
26    }
27
28    fn evaluate(&self, block_states: &[ParameterBlockState]) -> Result<FamilyEvaluation, String> {
29        crate::block_layout::block_count::validate_block_count::<TransformationNormalError>(
30            "TransformationNormalFamily",
31            1,
32            block_states.len(),
33        )?;
34        let evaluate_start = std::time::Instant::now();
35        let beta = &block_states[0].beta;
36        let row_q_start = std::time::Instant::now();
37        let row_quantities = self.row_quantities(beta)?;
38        log::info!(
39            "[STAGE] CTN row_quantities (h, h', 1/h', powers) n={} elapsed={:.3}s",
40            row_quantities.h.len(),
41            row_q_start.elapsed().as_secs_f64(),
42        );
43        let h = row_quantities.h.as_ref();
44        let n = h.len();
45
46        let log_likelihood = row_quantities.log_likelihood;
47        // SCOP gradient and exact negative Hessian. Response column 0 is the
48        // affine location component b(x); response columns >=1 are direct
49        // non-negative alpha shape fields.
50        let grad_start = std::time::Instant::now();
51        let (grad, hessian) = self.scop_gradient_and_negative_hessian(beta, &row_quantities)?;
52        log::info!(
53            "[STAGE] CTN gradient terms n={} p={} elapsed={:.3}s",
54            n,
55            grad.len(),
56            grad_start.elapsed().as_secs_f64(),
57        );
58
59        let hess_start = std::time::Instant::now();
60        let p_dim = hessian.nrows() as u64;
61        let n_u64 = n as u64;
62        log::info!(
63            "[STAGE] CTN hessian terms (SCOP exact dense) n={} p={} flops~{} elapsed={:.3}s",
64            n,
65            p_dim,
66            n_u64.saturating_mul(p_dim).saturating_mul(p_dim),
67            hess_start.elapsed().as_secs_f64(),
68        );
69        log::info!(
70            "[STAGE] CTN evaluate end n={} p={} elapsed={:.3}s",
71            n,
72            p_dim,
73            evaluate_start.elapsed().as_secs_f64(),
74        );
75
76        Ok(FamilyEvaluation {
77            log_likelihood,
78            blockworking_sets: vec![BlockWorkingSet::ExactNewton {
79                gradient: grad,
80                hessian: SymmetricMatrix::Dense(hessian),
81            }],
82        })
83    }
84
85    fn log_likelihood_only(&self, block_states: &[ParameterBlockState]) -> Result<f64, String> {
86        crate::block_layout::block_count::validate_block_count::<TransformationNormalError>(
87            "TransformationNormalFamily",
88            1,
89            block_states.len(),
90        )?;
91        // The line search uses NEG_INFINITY as the barrier-violation signal,
92        // so we can't propagate the row_quantities Err here. Translate any
93        // h' validation failure back into the NEG_INFINITY rejection contract.
94        let row_quantities = match self.row_quantities(&block_states[0].beta) {
95            Ok(rq) => rq,
96            Err(_) => return Ok(f64::NEG_INFINITY),
97        };
98        Ok(row_quantities.log_likelihood)
99    }
100
101    fn log_likelihood_only_with_options(
102        &self,
103        block_states: &[ParameterBlockState],
104        options: &BlockwiseFitOptions,
105    ) -> Result<f64, String> {
106        // When an outer-score subsample is installed, route through a
107        // mask-aware family clone whose `effective_weights()` returns the
108        // HT-weighted per-row weights. Because every term inside
109        // `build_transformation_row_derived` is linear in `wᵢ`, the row-LL
110        // accumulator yields `Σᵢ (mᵢ · wᵢ) · row_ll_i` — the unbiased
111        // Horvitz-Thompson estimator of the full-data LL.
112        match self.maybe_with_outer_subsample_from_options(options) {
113            Ok(Some(masked)) => masked.log_likelihood_only(block_states),
114            Ok(None) => self.log_likelihood_only(block_states),
115            Err(e) => Err(e.into()),
116        }
117    }
118
119    /// Log-likelihood + flat joint gradient without building the dense Hessian.
120    ///
121    /// The default trait implementation returns `None`, so the joint-Newton
122    /// inner solver falls back to `evaluate()` to obtain the gradient — and
123    /// that side-effects a full `Θ(n p²)` `weighted_gram` Hessian build at
124    /// every inner iteration. CTN's gradient is structurally
125    ///
126    ///   `∇ℓ = -X_val^T (w·h) + X_deriv^T (w/h')`,
127    ///
128    /// which is two `transpose_mul`s through the existing Khatri-Rao operators
129    /// and one `Θ(n)` row reduction — `Θ(n p)` total. At large scale that is
130    /// ~10⁷ FLOPs per call versus ~3·10¹⁰ for the full `evaluate`, so wiring
131    /// this override is the gating condition for routing CTN's inner solve
132    /// through the matrix-free joint-Newton path without paying the dense H
133    /// tax on every gradient refresh.
134    fn exact_newton_joint_gradient_evaluation(
135        &self,
136        block_states: &[ParameterBlockState],
137        specs: &[ParameterBlockSpec],
138    ) -> Result<Option<ExactNewtonJointGradientEvaluation>, String> {
139        crate::block_layout::block_count::validate_block_count::<TransformationNormalError>(
140            "TransformationNormalFamily",
141            1,
142            block_states.len(),
143        )?;
144        if !self.inner_coefficient_hessian_hvp_available(specs) {
145            return Err(TransformationNormalError::InvalidInput {
146                reason: "TransformationNormalFamily joint gradient evaluation received incompatible block specs"
147                    .to_string(),
148            }
149            .into());
150        }
151        let beta = &block_states[0].beta;
152        let row_quantities = self.row_quantities(beta)?;
153        let log_likelihood = row_quantities.log_likelihood;
154        let gradient = self.scop_gradient(beta, &row_quantities)?;
155        Ok(Some(ExactNewtonJointGradientEvaluation {
156            log_likelihood,
157            gradient,
158        }))
159    }
160
161    fn exact_newton_joint_hessian_beta_dependent(&self) -> bool {
162        // The Hessian depends on β through 1/h'² where h' = X_deriv · β.
163        true
164    }
165
166    fn joint_jeffreys_term_required(&self) -> bool {
167        // CTN models a continuous response through a monotone transformation
168        // `h(Y|x) ~ N(0,1)`; there is no separation/under-identification
169        // regime to bound. The Fisher information is `O(n)` on every
170        // identified direction at every working point, so the conditioning
171        // gate inside `joint_jeffreys_term` smooth-steps the contribution to
172        // zero as soon as `λ_min ≥ 16`. The construction up to that gate is
173        // not free though: each evaluation runs `p` SCOP directional
174        // derivatives of the joint Hessian, called three times per inner
175        // cycle (head-KKT gradient, joint Newton step RHS, post-step KKT
176        // residual) and once per outer evaluation. At large scale —
177        // `bench/large_scale` `rust_margslope_aniso_duchon16d_*` with
178        // `p=144`, `n=20000` — that single source dominates each inner
179        // cycle (~230 s/cycle observed in CI; ~5 700 cycles × 5.7 min
180        // ⇒ multi-hour hang) and exhausts the 40-minute CI budget before
181        // the inner solve converges. Disabling the term here keeps the
182        // un-augmented inner Newton path (still consistent with the
183        // outer LAML logdet, which also drops the `H_Φ` contribution
184        // through the same family gate).
185        false
186    }
187
188    fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
189        // Khatri–Rao tensor design: the coefficient block is X = R ⊙ C with
190        // rows length p_resp · p_cov. Two regimes:
191        //
192        // * **Dense regime** (small enough that the unified evaluator builds
193        //   `weighted_gram` directly): per-evaluation cost is the dense
194        //   `n · (p_resp · p_cov)²` Khatri–Rao gram build.
195        //
196        // * **Matrix-free regime** (large enough that
197        //   `use_joint_matrix_free_path` returns true and the evaluator
198        //   factors `H v` through `forward_mul` / `transpose_mul` on the
199        //   Khatri–Rao operands): per-`Hv` matvec cost is just
200        //   `n · (p_resp + p_cov)` flops — see `ctn_matrix_free_workspace`.
201        //   This is only an inner coefficient-space cost estimate; outer
202        //   θθ Hessian availability is declared separately.
203        let n_usize = self.response_val_basis.nrows();
204        let p_resp = self.response_val_basis.ncols() as u64;
205        let p_cov = self.covariate_design.ncols() as u64;
206        let expected_p_total = p_resp.saturating_mul(p_cov);
207        // Block-spec preview is optional. Callers without an assembled
208        // ParameterBlockSpec — cost estimators, planners, the
209        // BlockwiseFitOptions screen, every code path that asks "how
210        // expensive would the Hessian be on *this* family?" — pass `&[]`.
211        // The Khatri–Rao layout is fully determined by `p_resp · p_cov`
212        // from the family state, so fall back to `expected_p_total` for
213        // the empty-specs preview rather than returning the `u64::MAX`
214        // unreachable sentinel that would dominate every cost comparison.
215        // When specs IS supplied we still enforce the structural
216        // expectation `spec.design.ncols() == p_resp · p_cov`; a mismatch
217        // is the only condition that legitimately surfaces the sentinel.
218        let p_total = match specs {
219            [] => expected_p_total,
220            [spec] if spec.design.ncols() as u64 == expected_p_total => spec.design.ncols() as u64,
221            _ => return u64::MAX,
222        };
223        let n = n_usize as u64;
224        // Shared operator-aware gate (see `coefficient_cost`): matrix-free Hv
225        // streams the Khatri–Rao operands at `n · (p_resp + p_cov)`; the dense
226        // fallback is the `n · p_total²` Khatri–Rao gram build. The dense count
227        // is supplied inline rather than via `joint_coupled_coefficient_hessian_cost`
228        // because the empty-specs preview must still report `n · p_total²` from
229        // the family-derived `p_total`, not the `n · 0²` an empty `specs` sum yields.
230        crate::coefficient_cost::operator_aware_hessian_cost(
231            p_total,
232            n,
233            n.saturating_mul(p_resp.saturating_add(p_cov)),
234            n.saturating_mul(p_total.saturating_mul(p_total)),
235        )
236    }
237
238    fn coefficient_gradient_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
239        // One row-quantity pass plus two transpose products. The SCOP derivative
240        // is structurally positive, so coefficient line searches no longer run a
241        // full derivative-grid fraction-to-boundary scan on every attempt.
242        self.coefficient_hessian_cost(specs) / 2
243    }
244
245    fn outer_derivative_policy(
246        &self,
247        specs: &[crate::custom_family::ParameterBlockSpec],
248        psi_dim: usize,
249        options: &crate::custom_family::BlockwiseFitOptions,
250    ) -> crate::custom_family::OuterDerivativePolicy {
251        // The generic default model in `CustomFamily::outer_derivative_policy`
252        // uses `coefficient_hessian_cost × (rho_dim + psi_dim)`, which
253        // overstates CTN's actual per-eval Hessian work because the SCOP
254        // joint-Hessian path is row-streaming through the Khatri-Rao jet
255        // (its `O(n · p)` matrix-free HVP, not `O(n · p²)` dense build).
256        // Use a CTN-specific shape:
257        //
258        // * gradient ≈ `n · (rho_dim + psi_dim) · p_total`
259        //   (one directional jet sweep per outer coordinate, row-streamed)
260        // * Hessian  ≈ min(dense build, matrix-free HVP loop)
261        //   * dense  ≈ `n · (rho_dim + psi_dim) · p_total^2`
262        //   * mfree  ≈ `n · (rho_dim + psi_dim) · p_total · rho_dim`
263        let capability = self.exact_outer_derivative_order(specs, options);
264        let n = specs.first().map_or(0u128, |s| s.design.nrows() as u128);
265        let p_total: u128 = specs
266            .iter()
267            .map(|s| s.design.ncols() as u128)
268            .fold(0u128, |acc, x| acc.saturating_add(x));
269        let rho_dim: u128 = specs
270            .iter()
271            .map(|s| s.penalties.len() as u128)
272            .fold(0u128, |acc, x| acc.saturating_add(x));
273        let k = rho_dim.saturating_add(psi_dim as u128).max(1);
274        let p_eff = p_total.max(1);
275        // Gradient work: one row sweep per outer coordinate.
276        let work_grad = n.saturating_mul(k).saturating_mul(p_eff);
277        // Hessian work: pick whichever access shape would dominate. The
278        // amortization gate in `should_build_dense` (P2.2) picks the
279        // cheaper path at execution time; the policy budget mirrors that
280        // by taking the min so that genuinely Hessian-prohibitive
281        // problems still downgrade through the budget ceiling.
282        let dense_hess = work_grad.saturating_mul(p_eff);
283        let mfree_hess = work_grad.saturating_mul(rho_dim.max(1));
284        let work_hess = dense_hess.min(mfree_hess);
285        crate::custom_family::OuterDerivativePolicy {
286            capability,
287            predicted_hessian_work: work_hess,
288            predicted_gradient_work: work_grad,
289            // CTN's outer-score reductions are mathematically per-row
290            // sums whose contributions are linear in `wᵢ` at every assembly
291            // site (gradient, joint Hessian dense / matvec / diagonal, ψ,
292            // ψ-ψ, log-likelihood). The `_with_options` overrides install a
293            // mask-aware family clone whose `effective_weights()` returns
294            // `wᵢ · mᵢ` (HT-weighted), yielding an unbiased estimator
295            // `E[score_subsample] = score_full`. The persistent
296            // dense-Hessian cache is keyed on the mask hash so subsampled
297            // and full-data builds at the same β do not alias.
298            subsample_capable: true,
299        }
300    }
301
302    fn outer_seed_config(&self, n_params: usize) -> gam_solve::seeding::SeedConfig {
303        gam_solve::seeding::SeedConfig {
304            bounds: (-12.0, 12.0),
305            max_seeds: if n_params <= 8 { 1 } else { 2 },
306            seed_budget: 1,
307            screen_max_inner_iterations: 2,
308            risk_profile: gam_solve::seeding::SeedRiskProfile::Gaussian,
309            num_auxiliary_trailing: 0,
310            over_smoothing_probe_rho: None,
311        }
312    }
313
314    fn max_feasible_step_size(
315        &self,
316        block_states: &[ParameterBlockState],
317        block_index: usize,
318        delta: &Array1<f64>,
319    ) -> Result<Option<f64>, String> {
320        if block_index != 0 {
321            return Ok(None);
322        }
323        crate::block_layout::block_count::validate_block_count::<TransformationNormalError>(
324            "TransformationNormalFamily",
325            1,
326            block_states.len(),
327        )?;
328        if delta.len() != block_states[0].beta.len() {
329            return Err(TransformationNormalError::InvalidInput {
330                reason: format!(
331                    "CTN line-search step length {} != beta length {}",
332                    delta.len(),
333                    block_states[0].beta.len()
334                ),
335            }
336            .into());
337        }
338        // Feasibility is owned by the factored Khatri-Rao cone returned from
339        // `block_linear_constraints`. The active-set solve computes a feasible
340        // delta before globalization, so a second derivative-grid scan here
341        // would repeat the same cone work on every line-search attempt.
342        Ok(None)
343    }
344
345    fn block_linear_constraints(
346        &self,
347        block_states: &[ParameterBlockState],
348        block_index: usize,
349        block_spec: &ParameterBlockSpec,
350    ) -> Result<Option<ConstraintSet>, String> {
351        assert!(!block_spec.name.is_empty());
352        if block_index != 0 {
353            return Ok(None);
354        }
355        // The cone built below constrains the coefficients of exactly this
356        // block, so its width has to agree with the state the active-set solve
357        // will carry, not only with the spec's design.
358        let Some(state) = block_states.get(block_index) else {
359            return Err(format!(
360                "CTN linear constraints: block index {block_index} out of range for {} parameter block state(s)",
361                block_states.len(),
362            ));
363        };
364        if state.beta.len() != block_spec.design.ncols() {
365            return Err(format!(
366                "CTN linear constraints: block '{}' state width {} != spec design width {}",
367                block_spec.name,
368                state.beta.len(),
369                block_spec.design.ncols(),
370            ));
371        }
372        // Direct-alpha CTN is linear in the coefficient matrix A. The response
373        // derivative basis is non-negative, so global monotonicity on the
374        // realized covariate rows is exactly the factored cone
375        //
376        //     alpha_k(x_i) = psi_i^T A[k,:] >= 0,
377        //     i = 1..n, k = 1..p_resp-1.
378        //
379        // Row zero is the unconstrained location field. Keep the cone factored:
380        // materializing n*(p_resp-1) by p_resp*p_cov would turn the large-scale
381        // CTN preprocessor into a multi-gigabyte dense constraint matrix.
382        let p_resp = self.response_val_basis.ncols();
383        if p_resp <= 1 {
384            return Ok(None);
385        }
386        let factor = self.covariate_dense_arc()?;
387        let cone = KhatriRaoConeConstraints::new(factor, (1..p_resp).collect(), p_resp)?;
388        if cone.ncols() != block_spec.design.ncols() {
389            return Err(format!(
390                "CTN factored monotonicity cone width {} != coefficient block width {}",
391                cone.ncols(),
392                block_spec.design.ncols(),
393            ));
394        }
395        Ok(Some(ConstraintSet::KhatriRaoCone(cone)))
396    }
397
398    fn exact_newton_hessian_directional_derivative(
399        &self,
400        block_states: &[ParameterBlockState],
401        block_index: usize,
402        d_beta: &Array1<f64>,
403    ) -> Result<Option<Array2<f64>>, String> {
404        if block_index != 0 {
405            return Ok(None);
406        }
407        let beta = &block_states[0].beta;
408        let row_quantities = self.row_quantities(beta)?;
409        let dd = self.scop_hessian_directional_derivative(beta, d_beta, &row_quantities)?;
410        Ok(Some(dd))
411    }
412
413    fn exact_newton_joint_hessian(
414        &self,
415        block_states: &[ParameterBlockState],
416    ) -> Result<Option<Array2<f64>>, String> {
417        // Single block: joint Hessian = block Hessian.
418        let beta = &block_states[0].beta;
419        let row_quantities = self.row_quantities(beta)?;
420        let (_, hessian) = self.scop_gradient_and_negative_hessian(beta, &row_quantities)?;
421        Ok(Some(hessian))
422    }
423
424    fn exact_newton_joint_hessian_directional_derivative(
425        &self,
426        block_states: &[ParameterBlockState],
427        d_beta_flat: &Array1<f64>,
428    ) -> Result<Option<Array2<f64>>, String> {
429        self.exact_newton_hessian_directional_derivative(block_states, 0, d_beta_flat)
430    }
431
432    fn exact_newton_joint_hessiansecond_directional_derivative(
433        &self,
434        block_states: &[ParameterBlockState],
435        d_beta_u_flat: &Array1<f64>,
436        d_beta_v_flat: &Array1<f64>,
437    ) -> Result<Option<Array2<f64>>, String> {
438        let beta = &block_states[0].beta;
439        let row_quantities = self.row_quantities(beta)?;
440        let d2 = self.scop_hessian_second_directional_derivative(
441            beta,
442            d_beta_u_flat,
443            d_beta_v_flat,
444            &row_quantities,
445        )?;
446        Ok(Some(d2))
447    }
448
449    fn exact_newton_joint_psi_terms(
450        &self,
451        block_states: &[ParameterBlockState],
452        specs: &[ParameterBlockSpec],
453        hyper_layout: &CustomFamilyHyperLayout,
454        psi_index: usize,
455    ) -> Result<Option<ExactNewtonJointPsiTerms>, String> {
456        if hyper_layout.family_axis_count() != 0 {
457            return Err(
458                "TransformationNormalFamily does not declare family-owned hyper axes".to_string(),
459            );
460        }
461        if !self.inner_coefficient_hessian_hvp_available(specs) {
462            return Err(TransformationNormalError::InvalidInput {
463                reason:
464                    "TransformationNormalFamily joint psi terms received incompatible block specs"
465                        .to_string(),
466            }
467            .into());
468        }
469        let psi_derivs = hyper_layout.design_derivative_blocks();
470        if psi_derivs.is_empty() || psi_index >= psi_derivs[0].len() {
471            return Ok(None);
472        }
473        let psi_first_start = std::time::Instant::now();
474        let deriv = &psi_derivs[0][psi_index];
475        let beta = &block_states[0].beta;
476        let row = self.row_quantities(beta)?;
477        let op = deriv
478            .implicit_operator
479            .as_ref()
480            .and_then(|op| op.as_any().downcast_ref::<TensorKroneckerPsiOperator>())
481            .ok_or_else(|| {
482                "TransformationNormalFamily requires tensor psi derivatives to remain operator-backed"
483                    .to_string()
484            })?;
485        let axis = deriv.implicit_axis;
486        let op_arc = Arc::clone(
487            deriv
488                .implicit_operator
489                .as_ref()
490                .expect("validated CTN psi derivative operator disappeared"),
491        );
492        let terms = self.scop_psi_terms(beta, &row, op, op_arc, axis)?;
493
494        log::info!(
495            "[STAGE] CTN psi first-order terms axis={} psi_index={} elapsed={:.3}s",
496            deriv.implicit_axis,
497            psi_index,
498            psi_first_start.elapsed().as_secs_f64(),
499        );
500
501        Ok(Some(terms))
502    }
503
504    fn exact_newton_joint_psisecond_order_terms(
505        &self,
506        block_states: &[ParameterBlockState],
507        specs: &[ParameterBlockSpec],
508        hyper_layout: &CustomFamilyHyperLayout,
509        psi_i: usize,
510        psi_j: usize,
511    ) -> Result<Option<ExactNewtonJointPsiSecondOrderTerms>, String> {
512        if hyper_layout.family_axis_count() != 0 {
513            return Err(
514                "TransformationNormalFamily does not declare family-owned hyper axes".to_string(),
515            );
516        }
517        if !self.inner_coefficient_hessian_hvp_available(specs) {
518            return Err(TransformationNormalError::InvalidInput {
519                reason: "TransformationNormalFamily joint psi-psi terms received incompatible block specs"
520                    .to_string(),
521            }
522            .into());
523        }
524        let psi_derivs = hyper_layout.design_derivative_blocks();
525        if psi_derivs.is_empty() || psi_i >= psi_derivs[0].len() || psi_j >= psi_derivs[0].len() {
526            return Ok(None);
527        }
528        let psi_pair_start = std::time::Instant::now();
529        let deriv_i = &psi_derivs[0][psi_i];
530        let deriv_j = &psi_derivs[0][psi_j];
531        let beta = &block_states[0].beta;
532        let row = self.row_quantities(beta)?;
533        let p_resp = self.response_val_basis.ncols();
534        let p_cov = self.covariate_design.ncols();
535        let p_total = p_resp * p_cov;
536        if beta.len() != p_total {
537            return Err(TransformationNormalError::InvalidInput {
538                reason: format!(
539                    "SCOP psi-psi terms beta length {} != p_resp({p_resp}) * p_cov({p_cov})",
540                    beta.len()
541                ),
542            }
543            .into());
544        }
545
546        let op = deriv_i
547            .implicit_operator
548            .as_ref()
549            .and_then(|op| op.as_any().downcast_ref::<TensorKroneckerPsiOperator>())
550            .ok_or_else(|| {
551                "TransformationNormalFamily requires tensor psi derivatives to remain operator-backed"
552                    .to_string()
553            })?;
554        let axis_i = deriv_i.implicit_axis;
555        let axis_j = deriv_j.implicit_axis;
556
557        let (objective_psi_psi, score_psi_psi, _) = self
558            .scop_psi_psi_value_score_hvp_from_operator(
559                beta,
560                op,
561                axis_i,
562                axis_j,
563                row.alpha.view(),
564                row.h.view(),
565                row.h_prime.view(),
566                None,
567            )?;
568        let hessian_psi_psi_operator: Arc<dyn HyperOperator> =
569            Arc::new(TransformationNormalPsiPsiHessianOperator::new(
570                Arc::new(self.clone()),
571                beta.clone(),
572                Arc::clone(
573                    deriv_i
574                        .implicit_operator
575                        .as_ref()
576                        .expect("validated CTN psi derivative has an implicit operator"),
577                ),
578                axis_i,
579                axis_j,
580                Arc::clone(&row.alpha),
581                Arc::clone(&row.h),
582                Arc::clone(&row.h_prime),
583            ));
584
585        // Result-validation gate. A trial point can still make the SCOP row
586        // terms non-finite through an invalid h' or an exploding ψ second
587        // derivative in the covariate basis. Surface that as an infeasible
588        // exact-Newton evaluation instead of passing NaNs into the unified
589        // outer evaluator.
590        if !objective_psi_psi.is_finite() || !score_psi_psi.iter().all(|v| v.is_finite()) {
591            return Err(TransformationNormalError::NonFinite {
592                reason: format!(
593                    "TransformationNormalFamily exact ψ-ψ second-order terms produced \
594                 non-finite values at psi_i={psi_i}, psi_j={psi_j}: \
595                 obj_finite={}, score_all_finite={}. \
596                 The outer evaluator should retreat from this trial point.",
597                    objective_psi_psi.is_finite(),
598                    score_psi_psi.iter().all(|v| v.is_finite()),
599                ),
600            }
601            .into());
602        }
603
604        log::info!(
605            "[STAGE] CTN psi-psi pair (psi_i={}, psi_j={}, axes={},{}) elapsed={:.3}s",
606            psi_i,
607            psi_j,
608            deriv_i.implicit_axis,
609            deriv_j.implicit_axis,
610            psi_pair_start.elapsed().as_secs_f64(),
611        );
612
613        Ok(Some(ExactNewtonJointPsiSecondOrderTerms {
614            objective_psi_psi,
615            score_psi_psi,
616            hessian_psi_psi: Array2::zeros((0, 0)),
617            hessian_psi_psi_operator: Some(hessian_psi_psi_operator),
618        }))
619    }
620
621    fn exact_newton_joint_psihessian_directional_derivative(
622        &self,
623        block_states: &[ParameterBlockState],
624        specs: &[ParameterBlockSpec],
625        hyper_layout: &CustomFamilyHyperLayout,
626        psi_index: usize,
627        d_beta_flat: &Array1<f64>,
628    ) -> Result<Option<Array2<f64>>, String> {
629        if hyper_layout.family_axis_count() != 0 {
630            return Err(
631                "TransformationNormalFamily does not declare family-owned hyper axes".to_string(),
632            );
633        }
634        if !self.inner_coefficient_hessian_hvp_available(specs) {
635            return Err(TransformationNormalError::InvalidInput {
636                reason: "TransformationNormalFamily joint psi-Hessian directional derivative received incompatible block specs"
637                    .to_string(),
638            }
639            .into());
640        }
641        let psi_derivs = hyper_layout.design_derivative_blocks();
642        if psi_derivs.is_empty() || psi_index >= psi_derivs[0].len() {
643            return Ok(None);
644        }
645        let deriv = &psi_derivs[0][psi_index];
646        let beta = &block_states[0].beta;
647        let op = deriv
648            .implicit_operator
649            .as_ref()
650            .and_then(|op| op.as_any().downcast_ref::<TensorKroneckerPsiOperator>())
651            .ok_or_else(|| {
652                "TransformationNormalFamily requires tensor psi derivatives to remain operator-backed"
653                    .to_string()
654            })?;
655        let axis = deriv.implicit_axis;
656        let row = self.row_quantities(beta)?;
657        let hess =
658            self.scop_psi_hessian_directional_derivative(beta, d_beta_flat, &row, op, axis)?;
659        Ok(Some(hess))
660    }
661
662    fn exact_newton_joint_hessian_workspace(
663        &self,
664        block_states: &[ParameterBlockState],
665        specs: &[ParameterBlockSpec],
666    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
667        crate::block_layout::block_count::validate_block_count::<TransformationNormalError>(
668            "TransformationNormalFamily",
669            1,
670            block_states.len(),
671        )?;
672        if !self.inner_coefficient_hessian_hvp_available(specs) {
673            return Err(TransformationNormalError::InvalidInput {
674                reason: "TransformationNormalFamily joint Hessian workspace received incompatible block specs"
675                    .to_string(),
676            }
677            .into());
678        }
679        let beta = &block_states[0].beta;
680        let row_quantities = self.row_quantities(beta)?;
681        // Expected HVP reuse this workspace will service before its
682        // `(β, row_quantities)` key advances. The outer-eval trace path
683        // performs ~`2·rho_dim` HVPs plus one diagonal call against the
684        let workspace = TransformationNormalJointHessianWorkspace::new(
685            Arc::new(self.clone()),
686            beta.clone(),
687            row_quantities.clone(),
688        )?;
689        Ok(Some(
690            Arc::new(workspace) as Arc<dyn ExactNewtonJointHessianWorkspace>
691        ))
692    }
693
694    fn exact_newton_joint_psi_workspace(
695        &self,
696        block_states: &[ParameterBlockState],
697        specs: &[ParameterBlockSpec],
698        hyper_layout: &CustomFamilyHyperLayout,
699    ) -> Result<Option<Arc<dyn ExactNewtonJointPsiWorkspace>>, String> {
700        if hyper_layout.family_axis_count() != 0 {
701            return Err(
702                "TransformationNormalFamily does not declare family-owned hyper axes".to_string(),
703            );
704        }
705        if !self.inner_coefficient_hessian_hvp_available(specs) {
706            return Err(TransformationNormalError::InvalidInput {
707                reason: "TransformationNormalFamily joint psi workspace received incompatible block specs"
708                    .to_string(),
709            }
710            .into());
711        }
712        Ok(Some(Arc::new(TransformationNormalPsiWorkspace::new(
713            self.clone(),
714            block_states.to_vec(),
715            hyper_layout.design_derivative_blocks().to_vec(),
716        ))))
717    }
718
719    fn exact_newton_joint_hessian_workspace_with_options(
720        &self,
721        block_states: &[ParameterBlockState],
722        specs: &[ParameterBlockSpec],
723        options: &BlockwiseFitOptions,
724    ) -> Result<Option<Arc<dyn ExactNewtonJointHessianWorkspace>>, String> {
725        // Route through a mask-aware family clone when an outer-score
726        // subsample is active. The cloned family's `effective_weights()`
727        // returns `wᵢ · mᵢ` (`mᵢ = 1/πᵢ` on sampled rows, `0` elsewhere),
728        // and every CTN assembly site reads weights through that accessor.
729        // Each per-row contribution is linear in `wᵢ`, so the workspace's
730        // gradient / dense Hessian / matrix-free HVP / diagonal are exact
731        // Horvitz-Thompson estimators of the full-data quantities.
732        match self.maybe_with_outer_subsample_from_options(options)? {
733            Some(masked) => masked.exact_newton_joint_hessian_workspace(block_states, specs),
734            None => self.exact_newton_joint_hessian_workspace(block_states, specs),
735        }
736    }
737
738    fn exact_newton_joint_psi_workspace_with_options(
739        &self,
740        block_states: &[ParameterBlockState],
741        specs: &[ParameterBlockSpec],
742        hyper_layout: &CustomFamilyHyperLayout,
743        options: &BlockwiseFitOptions,
744    ) -> Result<Option<Arc<dyn ExactNewtonJointPsiWorkspace>>, String> {
745        if hyper_layout.family_axis_count() != 0 {
746            return Err(
747                "TransformationNormalFamily does not declare family-owned hyper axes".to_string(),
748            );
749        }
750        if !self.inner_coefficient_hessian_hvp_available(specs) {
751            return Err(TransformationNormalError::InvalidInput {
752                reason: "TransformationNormalFamily joint psi workspace received incompatible block specs"
753                    .to_string(),
754            }
755            .into());
756        }
757        // Route through a mask-aware family clone when an outer-score
758        // subsample is active. Every CTN ψ assembly site — including the
759        // workspace's `compute_all_axes` (per-row reduction near line ~13916)
760        // and `compute_pair_cache` (per-row reduction near line ~14263) —
761        // reads its row weight via `self.family.effective_weights()`, which
762        // on the cloned family returns `wᵢ · mᵢ`. Because each per-row
763        // contribution is linear in `wᵢ`, the workspace's per-axis ψ and
764        // per-axis-pair ψ-ψ outputs are exact Horvitz-Thompson estimators
765        // of the full-data quantities. The persistent dense-Hessian cache
766        // and `row_quantity_cache` on the cloned family are fresh, so
767        // subsampled builds cannot alias a later full-data probe at the
768        // same β.
769        let family = match self.maybe_with_outer_subsample_from_options(options)? {
770            Some(masked) => masked,
771            None => self.clone(),
772        };
773        Ok(Some(Arc::new(TransformationNormalPsiWorkspace::new(
774            family,
775            block_states.to_vec(),
776            hyper_layout.design_derivative_blocks().to_vec(),
777        ))))
778    }
779
780    fn exact_newton_joint_psi_workspace_for_first_order_terms(&self) -> bool {
781        // CTN's per-axis [`scop_psi_terms`] kernel walks all `n` rows serially
782        // and is invoked once per ψ axis. Opting in here amortizes the per-row
783        // state load across axes and parallelizes the row walk via the
784        // workspace's [`compute_all_axes`] kernel — the dominant outer
785        // gradient-evaluation cost at large scale.
786        true
787    }
788
789    fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
790        // CTN's SCOP coefficient-space joint Hessian is supplied as a
791        // row-streaming matrix-free Hv operator.
792        matches!(specs, [spec] if spec.design.ncols()
793            == self.response_val_basis.ncols().saturating_mul(self.covariate_design.ncols()))
794    }
795
796    fn outer_hyper_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
797        self.inner_coefficient_hessian_hvp_available(specs)
798    }
799
800    fn outer_hyper_hessian_dense_available(&self, specs: &[ParameterBlockSpec]) -> bool {
801        // Dense materialization remains mathematically available through the
802        // outer-HVP operator, but SCOP's primary production path is the
803        // matrix-free θθ operator above.
804        self.inner_coefficient_hessian_hvp_available(specs)
805    }
806}