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