1use super::*;
2use gam_problem::{ConstraintSet, KhatriRaoConeConstraints};
3
4impl CustomFamily for TransformationNormalFamily {
9 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 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 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 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 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 true
164 }
165
166 fn joint_jeffreys_term_required(&self) -> bool {
167 false
186 }
187
188 fn coefficient_hessian_cost(&self, specs: &[ParameterBlockSpec]) -> u64 {
189 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 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 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 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 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 let work_grad = n.saturating_mul(k).saturating_mul(p_eff);
277 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 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 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 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 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 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 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 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 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 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 true
787 }
788
789 fn inner_coefficient_hessian_hvp_available(&self, specs: &[ParameterBlockSpec]) -> bool {
790 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 self.inner_coefficient_hessian_hvp_available(specs)
805 }
806}