Skip to main content

gam_models/transformation_normal/
custom_family.rs

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