1use super::*;
2
3#[derive(Clone)]
4pub(crate) struct TransformationExactGeometryCache {
5 pub(crate) key: Vec<u64>,
6 pub(crate) covariate_spec_resolved: TermCollectionSpec,
7 pub(crate) covariate_design: TermCollectionDesign,
8 pub(crate) family: TransformationNormalFamily,
9 pub(crate) blocks: Vec<ParameterBlockSpec>,
10 pub(crate) hyper_layout: SharedCustomFamilyHyperLayout,
11}
12
13#[derive(Default)]
14pub(crate) struct TransformationExactModeBranch {
15 carried_mode: Option<CustomFamilyWarmStart>,
16 anchor: Option<CustomFamilyWarmStart>,
17 frozen: bool,
18}
19
20impl TransformationExactModeBranch {
21 pub(crate) fn candidates(
28 &mut self,
29 eval_mode: gam_problem::EvalMode,
30 rho: &Array1<f64>,
31 ) -> (bool, Vec<Option<CustomFamilyWarmStart>>) {
32 let froze = self.prepare(eval_mode);
33 let carried = self
34 .frozen
35 .then_some(&self.anchor)
36 .unwrap_or(&self.carried_mode)
37 .as_ref()
38 .filter(|warm| warm.compatible_with_rho(rho))
39 .cloned();
40 match carried {
41 Some(warm) => (froze, vec![None, Some(warm)]),
42 None => (froze, vec![None]),
43 }
44 }
45
46 pub(crate) fn prepare(&mut self, eval_mode: gam_problem::EvalMode) -> bool {
50 if self.frozen || matches!(eval_mode, gam_problem::EvalMode::ValueOnly) {
51 return false;
52 }
53 self.anchor = self.carried_mode.take();
54 self.frozen = true;
55 true
56 }
57
58 pub(crate) fn record_value(
61 &mut self,
62 eval_mode: gam_problem::EvalMode,
63 warm_start: CustomFamilyWarmStart,
64 ) {
65 if !self.frozen && matches!(eval_mode, gam_problem::EvalMode::ValueOnly) {
66 self.carried_mode = Some(warm_start);
67 }
68 }
69}
70
71impl TransformationExactGeometryCache {
72 pub(crate) fn update_block_log_lambdas(
73 &mut self,
74 log_lambdas: &Array1<f64>,
75 ) -> Result<(), String> {
76 let spec = self
77 .blocks
78 .first_mut()
79 .ok_or_else(|| "missing transformation block spec".to_string())?;
80 if log_lambdas.len() != spec.initial_log_lambdas.len() {
81 return Err(TransformationNormalError::InvalidInput {
82 reason: format!(
83 "transformation final fit rho length mismatch: got {}, expected {}",
84 log_lambdas.len(),
85 spec.initial_log_lambdas.len()
86 ),
87 }
88 .into());
89 }
90 gam_problem::validate_log_strengths(log_lambdas.iter().copied()).map_err(|error| {
91 TransformationNormalError::InvalidInput {
92 reason: format!("invalid transformation smoothing strength: {error}"),
93 }
94 })?;
95 spec.initial_log_lambdas = log_lambdas.clone();
96 Ok(())
97 }
98}
99
100pub(crate) fn transformation_spatial_geometry_key(
101 spec: &TermCollectionSpec,
102 spatial_terms: &[usize],
103) -> Result<Vec<u64>, String> {
104 let mut key = Vec::new();
105 key.push(spatial_terms.len() as u64);
106 for &term_idx in spatial_terms {
107 let term = spec.smooth_terms.get(term_idx).ok_or_else(|| {
108 format!(
109 "transformation spatial geometry key term index {term_idx} out of range for {} smooth terms",
110 spec.smooth_terms.len()
111 )
112 })?;
113 key.push(term_idx as u64);
114
115 let payload = serde_json::to_vec(term).map_err(|err| {
123 format!("failed to serialize transformation spatial geometry term {term_idx}: {err}")
124 })?;
125 key.push(payload.len() as u64);
126 for chunk in payload.chunks(8) {
127 let mut bytes = [0u8; 8];
128 for (dst, src) in bytes.iter_mut().zip(chunk.iter().copied()) {
129 *dst = src;
130 }
131 key.push(u64::from_le_bytes(bytes));
132 }
133 }
134 Ok(key)
135}
136
137#[derive(Clone)]
143pub struct TransformationNormalFitResult {
144 pub family: TransformationNormalFamily,
145 pub fit: UnifiedFitResult,
146 pub covariate_spec_resolved: TermCollectionSpec,
147 pub covariate_design: TermCollectionDesign,
148 pub score_calibration: TransformationScoreCalibration,
149}
150
151pub fn fit_transformation_normal(
159 response: &Array1<f64>,
160 weights: &Array1<f64>,
161 offset: &Array1<f64>,
162 covariate_data: ArrayView2<'_, f64>,
163 covariate_spec: &TermCollectionSpec,
164 config: &TransformationNormalConfig,
165 options: &BlockwiseFitOptions,
166 kappa_options: &SpatialLengthScaleOptimizationOptions,
167 warm_start: Option<&TransformationWarmStart>,
168) -> Result<TransformationNormalFitResult, String> {
169 let mut options = options.clone();
170 let covariate_spec = covariate_spec.clone();
175
176 let boot_design = build_term_collection_design(covariate_data, &covariate_spec)
179 .map_err(|e| format!("failed to build bootstrap covariate design: {e}"))?;
180 let boot_spec = freeze_term_collection_from_design(&covariate_spec, &boot_design)
181 .map_err(|e| format!("failed to freeze bootstrap covariate spatial basis centers: {e}"))?;
182 let mut effective_config = config.clone();
183 if !config.response_num_internal_knots_pinned {
189 effective_config.response_num_internal_knots = effective_response_num_internal_knots(
190 config,
191 response.len(),
192 boot_design.design.ncols(),
193 response.view(),
194 );
195 }
196
197 let (resp_val, resp_deriv, resp_penalties, resp_knots, resp_transform) =
200 build_response_basis(response, &effective_config)?;
201
202 let realized_p_total = resp_val.ncols().saturating_mul(boot_design.design.ncols());
210 let ctn_inner_cap = CTN_INNER_MAX_CYCLES_BASE
211 .saturating_add(realized_p_total.saturating_mul(CTN_INNER_MAX_CYCLES_PER_DIM))
212 .min(CTN_INNER_MAX_CYCLES_CEILING);
213 options.inner_max_cycles = options.inner_max_cycles.min(ctn_inner_cap);
214
215 let spatial_terms = spatial_length_scale_term_indices(&covariate_spec);
217
218 if spatial_terms.is_empty() || !kappa_options.enabled {
219 let cov_design = boot_design;
223 let cov_spec_resolved = boot_spec;
224 let effective_offset = cov_design
225 .compose_offset(offset.view(), "transformation-normal fit")
226 .map_err(|error| error.to_string())?;
227
228 let family = TransformationNormalFamily::from_prebuilt_response_basis(
229 response,
230 resp_val,
231 resp_deriv,
232 resp_penalties,
233 resp_knots.clone(),
234 effective_config.response_degree,
235 resp_transform,
236 weights,
237 &effective_offset,
238 cov_design.design.clone(),
239 cov_design
240 .penalties
241 .iter()
242 .map(|bp| bp.to_penalty_matrix(cov_design.design.ncols()))
243 .collect(),
244 &effective_config,
245 warm_start,
246 )?;
247 let rho0 = family.penalty_scale_log_lambdas()?;
248 let blocks = vec![family.block_spec(&rho0)?];
249 let fit = fit_custom_family(&family, &blocks, &options)
250 .map_err(|e| format!("transformation fit failed: {e}"))?;
251 let (fit, score_calibration) = calibrate_transformation_scores(&family, fit)?;
252
253 return Ok(TransformationNormalFitResult {
254 family,
255 fit,
256 covariate_spec_resolved: cov_spec_resolved,
257 covariate_design: cov_design,
258 score_calibration,
259 });
260 }
261
262 let kappa0 = SpatialLogKappaCoords::from_length_scales_aniso(
267 &covariate_spec,
268 &spatial_terms,
269 kappa_options,
270 )
271 .reseed_from_data(
272 covariate_data,
273 &covariate_spec,
274 &spatial_terms,
275 kappa_options,
276 )
277 .map_err(|error| error.to_string())?;
278 let kappa_dims = kappa0.dims_per_term().to_vec();
279 let kappa_lower = SpatialLogKappaCoords::lower_bounds_aniso_from_data(
280 covariate_data,
281 &covariate_spec,
282 &spatial_terms,
283 &kappa_dims,
284 kappa_options,
285 )
286 .map_err(|error| error.to_string())?;
287 let kappa_upper = SpatialLogKappaCoords::upper_bounds_aniso_from_data(
288 covariate_data,
289 &covariate_spec,
290 &spatial_terms,
291 &kappa_dims,
292 kappa_options,
293 )
294 .map_err(|error| error.to_string())?;
295 let kappa0 = kappa0.clamp_to_bounds(&kappa_lower, &kappa_upper);
297
298 let analytic_psi_available =
300 build_block_spatial_psi_derivatives(covariate_data, &boot_spec, &boot_design)?.is_some();
301
302 let probe_design = build_term_collection_design(covariate_data, &boot_spec)
316 .map_err(|e| format!("failed to rebuild frozen probe covariate design: {e}"))?;
317 let probe_offset = probe_design
318 .compose_offset(offset.view(), "transformation-normal spatial probe")
319 .map_err(|error| error.to_string())?;
320
321 let probe_family = TransformationNormalFamily::from_prebuilt_response_basis(
323 response,
324 resp_val.clone(),
325 resp_deriv.clone(),
326 resp_penalties.clone(),
327 resp_knots.clone(),
328 effective_config.response_degree,
329 resp_transform.clone(),
330 weights,
331 &probe_offset,
332 probe_design.design.clone(),
333 probe_design
334 .penalties
335 .iter()
336 .map(|bp| bp.to_penalty_matrix(probe_design.design.ncols()))
337 .collect(),
338 &effective_config,
339 warm_start,
340 )?;
341 let rho0 = probe_family.penalty_scale_log_lambdas()?;
342 let probe_block = probe_family.block_spec(&rho0)?;
343 let n_penalties = probe_block.initial_log_lambdas.len();
344 log::info!(
345 "[transformation-normal] exact joint setup: rho_dim={} log_kappa_dim={} dims_per_term={:?}",
346 n_penalties,
347 kappa0.len(),
348 kappa_dims,
349 );
350 let rho_floor = -12.0;
351 let rho_lower = Array1::<f64>::from_elem(n_penalties, rho_floor);
352 let rho_upper = Array1::<f64>::from_elem(n_penalties, 12.0);
353 let probe_blocks = vec![probe_block.clone()];
354 let (_, cap_hessian) = crate::custom_family::custom_family_outer_derivatives(
355 &probe_family,
356 &probe_blocks,
357 &options,
358 );
359 let analytic_gradient = analytic_psi_available;
360 let analytic_hessian_supported = analytic_gradient && cap_hessian.is_analytic();
361 let analytic_hessian = analytic_hessian_supported;
362 if analytic_hessian {
363 log::info!(
364 "[transformation-normal] CTN exact joint analytic outer Hessian is available for spatial kappa optimization; using exact second-order outer geometry"
365 );
366 }
367
368 let (rho0_min, rho0_max) = if rho0.is_empty() {
369 (0.0, 0.0)
370 } else {
371 (
372 rho0.iter().copied().fold(f64::INFINITY, f64::min),
373 rho0.iter().copied().fold(f64::NEG_INFINITY, f64::max),
374 )
375 };
376 log::info!(
377 "[transformation-normal] skipping baseline custom-family prefit before exact joint optimization \
378 (rho_dim={}, log_kappa_dim={}, rho0_range=[{:.3}, {:.3}]); using CTN warm start and penalty-scale rho seed",
379 n_penalties,
380 kappa0.len(),
381 rho0_min,
382 rho0_max,
383 );
384
385 if !analytic_psi_available {
386 return Err(
387 "transformation-normal spatial length-scale optimization requires analytic spatial psi derivatives"
388 .to_string(),
389 );
390 }
391
392 let exact_mode_branch: RefCell<TransformationExactModeBranch> =
398 RefCell::new(TransformationExactModeBranch::default());
399
400 let joint_setup =
401 ExactJointHyperSetup::new(rho0, rho_lower, rho_upper, kappa0, kappa_lower, kappa_upper);
402
403 let rv = resp_val.clone();
405 let rd = resp_deriv.clone();
406 let rp = resp_penalties.clone();
407 let rk = resp_knots.clone();
408 let rt = resp_transform.clone();
409 let rdeg = effective_config.response_degree;
410 let cfg = effective_config.clone();
411 let ws = warm_start.cloned();
412
413 let make_family =
415 |cov_design: &TermCollectionDesign| -> Result<TransformationNormalFamily, String> {
416 let effective_offset = cov_design
417 .compose_offset(offset.view(), "transformation-normal spatial fit")
418 .map_err(|error| error.to_string())?;
419 TransformationNormalFamily::from_prebuilt_response_basis(
420 response,
421 rv.clone(),
422 rd.clone(),
423 rp.clone(),
424 rk.clone(),
425 rdeg,
426 rt.clone(),
427 weights,
428 &effective_offset,
429 cov_design.design.clone(),
430 cov_design
431 .penalties
432 .iter()
433 .map(|bp| bp.to_penalty_matrix(cov_design.design.ncols()))
434 .collect(),
435 &cfg,
436 ws.as_ref(),
437 )
438 };
439
440 let block_specs_slice = [boot_spec.clone()];
441 let block_term_indices_slice = [spatial_terms.clone()];
442 let exact_geometry_cache: RefCell<Option<TransformationExactGeometryCache>> =
443 RefCell::new(None);
444 let spatial_terms_for_cache = spatial_terms.clone();
445
446 let ensure_exact_geometry = |spec: &TermCollectionSpec,
447 design: &TermCollectionDesign,
448 rho: &Array1<f64>,
449 hyper_values: &Array1<f64>|
450 -> Result<(), String> {
451 let effective_spec = freeze_term_collection_from_design(spec, design)
452 .map_err(|e| format!("failed to freeze transformation geometry key: {e}"))?;
453 let key = transformation_spatial_geometry_key(&effective_spec, &spatial_terms_for_cache)?;
454 let needs_rebuild = exact_geometry_cache
455 .borrow()
456 .as_ref()
457 .map(|cached| cached.key != key)
458 .unwrap_or(true);
459 if !needs_rebuild {
460 let mut cache = exact_geometry_cache.borrow_mut();
461 let cached = cache
462 .as_mut()
463 .ok_or_else(|| "missing transformation exact geometry cache".to_string())?;
464 if cached.hyper_layout.values().len() != hyper_values.len()
465 || cached
466 .hyper_layout
467 .values()
468 .iter()
469 .zip(hyper_values)
470 .any(|(cached, current)| cached.to_bits() != current.to_bits())
471 {
472 return Err(
473 "transformation exact geometry key reused across distinct hypercoordinate values"
474 .to_string(),
475 );
476 }
477 return cached.update_block_log_lambdas(rho);
478 }
479
480 let geom_start = std::time::Instant::now();
481 let exact_design = build_term_collection_design(covariate_data, &effective_spec)
482 .map_err(|e| format!("failed to rebuild frozen transformation geometry: {e}"))?;
483 let family = make_family(&exact_design)?;
484 let cov_psi_derivs =
485 build_block_spatial_psi_derivatives(covariate_data, &effective_spec, &exact_design)?
486 .ok_or_else(|| {
487 "missing covariate spatial psi derivatives for transformation model".to_string()
488 })?;
489 let tensor_derivs = build_tensor_psi_derivatives(&family, &cov_psi_derivs)?;
490
491 log::debug!(
492 "[transformation-normal] rebuilt exact geometry cache for {} spatial terms in {:.3}s",
493 spatial_terms_for_cache.len(),
494 geom_start.elapsed().as_secs_f64(),
495 );
496
497 exact_geometry_cache.replace(Some(TransformationExactGeometryCache {
498 key,
499 covariate_spec_resolved: effective_spec,
500 covariate_design: exact_design,
501 blocks: vec![family.block_spec(rho)?],
502 family,
503 hyper_layout: Arc::new(CustomFamilyHyperLayout::new(
504 vec![tensor_derivs],
505 Vec::new(),
506 hyper_values.clone(),
507 )?),
508 }));
509 Ok(())
510 };
511
512 let exact_mode_candidates = |eval_mode: gam_problem::EvalMode,
513 rho: &Array1<f64>|
514 -> Vec<Option<CustomFamilyWarmStart>> {
515 let (froze, candidates) = exact_mode_branch.borrow_mut().candidates(eval_mode, rho);
516 if froze {
517 log::info!(
518 "[transformation-normal] froze deterministic exact coefficient-mode branch at the first derivative-bearing outer seed evaluation"
519 );
520 }
521 candidates
522 };
523
524 log::info!(
525 "[transformation-normal] entering exact joint outer optimization \
526 (analytic_gradient={}, analytic_hessian={})",
527 analytic_gradient,
528 analytic_hessian,
529 );
530 let outer_derivative_policy =
535 probe_family.outer_derivative_policy(&probe_blocks, joint_setup.log_kappa_dim(), &options);
536 let solved = optimize_spatial_length_scale_exact_joint(
537 covariate_data,
538 &block_specs_slice,
539 &block_term_indices_slice,
540 kappa_options,
541 &joint_setup,
542 gam_solve::seeding::SeedRiskProfile::Gaussian,
543 analytic_gradient,
544 analytic_hessian,
545 true,
552 None,
553 outer_derivative_policy,
554 |theta,
556 specs: &[TermCollectionSpec],
557 designs: &[TermCollectionDesign],
558 provenance: SpatialFitProvenance<'_, CustomFamilyJointHyperModeSelection>| {
559 let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
560 let hyper_values = theta.slice(s![joint_setup.rho_dim()..]).to_owned();
561 ensure_exact_geometry(&specs[0], &designs[0], &rho, &hyper_values)?;
562 let mut cache_ref = exact_geometry_cache.borrow_mut();
563 let geometry = cache_ref
564 .as_mut()
565 .ok_or_else(|| "missing transformation exact geometry cache".to_string())?;
566 let final_options = crate::outer_subsample::exact_outer_options_for_row_set(
567 &options,
568 &gam_problem::outer_subsample::RowSet::All,
569 );
570 let fit = match provenance {
571 SpatialFitProvenance::NoOuterOptimization => {
572 let warm_starts =
573 exact_mode_candidates(gam_problem::EvalMode::ValueOnly, &rho);
574 let selection = evaluate_custom_family_joint_hyper_best_mode_shared(
575 &geometry.family,
576 &geometry.blocks,
577 &final_options,
578 &rho,
579 Arc::clone(&geometry.hyper_layout),
580 &warm_starts,
581 gam_problem::EvalMode::ValueOnly,
582 )
583 .map_err(|e| format!("transformation fixed mode profile: {e}"))?;
584 log::info!(
585 "[transformation-normal] user-fixed coefficient mode selected candidate={} objective={:.16e}",
586 selection.selected_candidate,
587 selection.result.objective,
588 );
589 fit_custom_family_user_fixed_log_lambdas_from_mode_selection(
590 &geometry.family,
591 &geometry.blocks,
592 &final_options,
593 selection,
594 )
595 }
596 SpatialFitProvenance::Certified { outer, mode: selection } => {
597 log::info!(
598 "[transformation-normal] consuming certified terminal coefficient mode candidate={} objective={:.16e} without profile replay",
599 selection.selected_candidate,
600 selection.result.objective,
601 );
602 fit_custom_family_fixed_log_lambdas_from_mode_selection(
603 &geometry.family,
604 &geometry.blocks,
605 &final_options,
606 selection,
607 theta,
608 outer,
609 )
610 }
611 }
612 .map_err(|e| format!("transformation fit_fn: {e}"))?;
613 if let Some(block) = fit.block_states.first() {
614 *geometry
615 .family
616 .row_quantity_cache
617 .lock()
618 .expect("CTN row quantity cache mutex poisoned") = None;
619 let final_rows = geometry.family.row_quantities(&block.beta)?;
620 let max_abs_h = final_rows
621 .h
622 .iter()
623 .copied()
624 .map(f64::abs)
625 .fold(0.0, f64::max);
626 let cov_chunk = geometry
627 .family
628 .covariate_design
629 .try_row_chunk(0..response.len())
630 .map_err(|err| {
631 format!("final CTN covariate design validation failed: {err}")
632 })?;
633 let max_abs_cov = cov_chunk.iter().copied().map(f64::abs).fold(0.0, f64::max);
634 log::info!(
635 "[transformation-normal] final fixed-rho CTN validation: max_abs_h={:.6e} max_abs_covariate_basis={:.6e}",
636 max_abs_h,
637 max_abs_cov
638 );
639 }
640 Ok(TransformationNormalFitResult {
641 family: geometry.family.clone(),
642 fit,
643 covariate_spec_resolved: geometry.covariate_spec_resolved.clone(),
644 covariate_design: geometry.covariate_design.clone(),
645 score_calibration: TransformationScoreCalibration::finite_support_pit(),
646 })
647 },
648 |theta,
650 specs: &[TermCollectionSpec],
651 designs: &[TermCollectionDesign],
652 eval_mode,
653 row_set| {
654 let rho = theta.slice(s![..joint_setup.rho_dim()]).to_owned();
655 let hyper_values = theta.slice(s![joint_setup.rho_dim()..]).to_owned();
656 ensure_exact_geometry(&specs[0], &designs[0], &rho, &hyper_values)?;
657 let mut cache_ref = exact_geometry_cache.borrow_mut();
658 let geometry = cache_ref
659 .as_mut()
660 .ok_or_else(|| "missing transformation exact geometry cache".to_string())?;
661 let warm_starts = exact_mode_candidates(eval_mode, &rho);
662 let competing_modes = warm_starts.len() > 1;
663 let eval_options =
667 crate::outer_subsample::exact_outer_options_for_row_set(&options, row_set);
668 let selection = evaluate_custom_family_joint_hyper_best_mode_shared(
669 &geometry.family,
670 &geometry.blocks,
671 &eval_options,
672 &rho,
673 Arc::clone(&geometry.hyper_layout),
674 &warm_starts,
675 eval_mode,
676 )
677 .map_err(|e| format!("transformation exact joint mode profile: {e}"))?;
678 for (candidate_idx, rejection) in selection.rejected_candidates.iter().enumerate() {
679 if let Some(rejection) = rejection {
680 log::warn!(
681 "[transformation-normal] rejected exact coefficient-mode candidate mode_candidate={candidate_idx}: {rejection}"
682 );
683 }
684 }
685 if competing_modes {
686 for (candidate_idx, objective) in selection.screened_objectives.iter().enumerate() {
687 if let Some(objective) = objective {
688 let source = if candidate_idx == 0 {
689 "cold"
690 } else {
691 "carried"
692 };
693 log::info!(
694 "[transformation-normal] exact coefficient-mode screen source={} objective={:.16e}",
695 source,
696 objective,
697 );
698 }
699 }
700 let selected_source = if selection.selected_candidate == 0 {
701 "cold"
702 } else {
703 "carried"
704 };
705 log::info!(
706 "[transformation-normal] selected exact coefficient mode source={} objective={:.16e}",
707 selected_source,
708 selection.result.objective,
709 );
710 }
711 let objective = selection.result.objective;
712 let gradient = selection.result.gradient.clone();
713 let outer_hessian = selection.result.outer_hessian.clone();
714 exact_mode_branch
715 .borrow_mut()
716 .record_value(eval_mode, selection.result.warm_start.clone());
717
718 Ok(ExactJointEvaluation {
719 objective,
720 gradient,
721 hessian: outer_hessian,
722 mode: selection,
723 })
724 },
725 |_theta,
726 _specs: &[TermCollectionSpec],
727 _designs: &[TermCollectionDesign],
728 _row_set| {
729 Err::<ExactJointEfsEvaluation<crate::custom_family::CustomFamilyJointHyperModeSelection>, String>("transformation-normal EFS callback invoked even though fixed-point optimization is disabled for beta-dependent exact curvature".to_string())
730 },
731 |_beta: &Array1<f64>| Ok(gam_solve::rho_optimizer::SeedOutcome::NoSlot),
732 )?;
733
734 let mut fit = solved.fit;
735 let (calibrated_fit, score_calibration) =
736 calibrate_transformation_scores(&fit.family, fit.fit.clone())?;
737 fit.fit = calibrated_fit;
738 fit.score_calibration = score_calibration;
739 Ok(fit)
740}